@avantf/dsh-compat
v0.1.0
Published
DSH plugin startup compatibility gate: prove the host API is the one this build was written against, refuse to load when it is not, and only warn on a version difference
Maintainers
Readme
@avantf/dsh-compat
DSH 插件的启动兼容性底座:在 apply() 最前面确认"宿主 API 还是本构建写的那一套",证明不兼容就拒绝加载,只有版本号不同则只警告。
不依赖任何 dsh 版本 —— 这是它的立身之本:src/ 只 import node 内置模块与 zod(peer),一个 @deepseek-ai/* 都没有;清单里不出现任何 dsh 版本号(dependencies/peerDependencies/devDependencies 都没有)。需要 dsh 声明的地方改成依赖注入:probeTool: toolProbeDeclaration(defineTool) 由调用方传自己的 helper(探针本来就该镜像插件自己的声明路径)。唯一的 dsh 接触在测试里 —— 它挂载宿主真实的注册表来验证探针,用的是你装的那份 dsh(scripts/link-dsh.mjs,不钉版本)。
dsh 变得快,而插件的整个 API 面都是 dsh 的。没有这道门时,失败方式恰恰是最难查的两种:apply 中途抛错(半个插件挂上了),或者插件挂上了却完全惰性(工具注册到空气里、唤醒闸门永远不进,一句报错都没有)。这个包把判断、探针与复查做成通用件,插件只需声明"自己调了什么"。
用法
npm i @avantf/dsh-compat # 或源码依赖:link:/file: 指向本仓库目录插件侧三处接线,全部集中在一个 provision.ts 里:
import { defineTool } from '@deepseek-ai/dsh-tools'
import {
provision, registerMegaphone, compatReport, verifyRegisteredFaces,
readBuildVersions, readDeclaredVersions, readRuntimeVersions, schemaNamesFrom, toolProbeDeclaration,
type CompatSpec,
} from '@avantf/dsh-compat'
const SPEC: CompatSpec = {
packageId: '@acme/my-plugin',
// 探针声明由你注入:包里不认识 dsh 类型,而探针必须是你自己的真身声明
probeTool: toolProbeDeclaration(defineTool),
// 传你的真身 contribution —— 这样「探针通过」就等于「接下来那次注册会通过」
probeTypert: () => myWireContribution,
// 本插件调用的服务与方法;required = 缺了就拒绝加载(cordis 的 inject 没有可选形式,
// 能降级用的服务用 ctx.get 读,这里写 required: false)
services: [
{ name: 'tools', required: true, methods: ['register', 'get'] },
{ name: 'typert', required: false, methods: ['register', 'get', 'list', 'listPackages', 'toJSONSchema'] },
],
// 「本构建编译时对着哪一版」:优先读构建时烧在产物旁边的 dsh-build.json(精确版本),
// 取不到才逐包回落到自身 package.json 的 peer 区间地板
declared: readBuildVersions(
new URL('./dsh-build.json', import.meta.url),
['@deepseek-ai/dsh-tools'],
readDeclaredVersions(new URL('../package.json', import.meta.url), ['@deepseek-ai/dsh-tools']),
),
// 本产物自己的链接解析出的版本(不是宿主版本 —— 见「已知边界」)
runtime: readRuntimeVersions(['@deepseek-ai/dsh-tools'], import.meta.url),
needsInterval: true,
schemaNames: schemaNamesFrom(myWireContribution),
events: ['agent/pre-step'],
}
export function apply(ctx: Context): void {
const log = myLogger(ctx.logger)
const verdict = provision(ctx, log, SPEC) // 第一步,什么都还没注册
if (!verdict.load) {
registerMegaphone({
ctx, log,
command: { name: 'myplugin', description: '报告插件为何未加载' },
text: compatReport(verdict, {
heading: '插件未加载:与当前 dsh 的兼容性检查未通过。',
warningsLabel: '风险提示:',
warnings: verdict.warnings,
fix: '修复:重新构建,或把 @deepseek-ai/dsh 换回本构建声明的版本。',
}),
})
return
}
// …正常挂载…
// 真身注册完之后:按精确 key 数一遍 schema、把工具名逐个读回(warn-only)
verifyRegisteredFaces({ ctx, log, packageId: SPEC.packageId, schemaNames: SPEC.schemaNames, toolNames })
}判据
| 结论 | 触发 | 后果 |
|---|---|---|
| 拒绝加载 | 必需服务或方法消失,或注册表拒绝了与真实声明同形的探针 | load: false:什么都不注册,日志给期望 vs 实际 |
| 警告 | 本产物自己链接解析出的版本与构建时编译对着的版本(烧入的 dsh-build.json,回落 peer 地板)不同 | 继续加载 |
| 只记一笔 | 版本解析不出来(compat: ok 行里写成 unknown)、事件名、未挂载的可选服务、没有查询面的注册表 | 继续加载 |
- 探针是主动的,而且必须是你自己的真身声明:
tools用你注入的defineTool造一个一次性工具(注册 →get读回 → 撤回);typert注册你给的那份 contribution(推荐probeTypert: () => 你的真身 contribution)→ 按它声明的 schema 名查get/list/listPackages/toJSONSchema→ 撤回。"register是个函数"和"注册我们真正注册的东西能成"是两回事,只有后者能抓到契约移动。 这条规则是踩出来的:本包曾提供一个"最小但像样"的默认探针,它在 dsh 0.1.6 上给出ok—— 因为它没有 codec,而 0.1.6 恰好改了 codec 契约(0.1.5 查schema.parse,0.1.6 查create())—— 紧接着真身注册在apply中途抛错,插件半挂载。所以现在没有默认探针:不传就是"未探测"(记一笔,不拦),要探就给真身。只有当真 contribution 确实没有 codec 时,才用minimalTypertProbeDeclaration并在注释里写明原因。 - 拒绝,但绝不抛错:
apply里抛出的异常不只影响你自己 —— Cordis loader 会让整次 update 失败并回滚(同一棵配置树里的每一行都被 dispose + 重新 create),而重新 create 可能撞上 storage-domain 的"同名域单开"(already-open)再也回不来。活证:某插件在 0.1.6 上因 codec 契约变化在apply中途抛错,回滚把邻居的工作区注册表搞成未挂载,界面里工作区像消失了一样。所以本包的规则是:把风险挪到加载前(探针 = 真身),失败就load: false+ 日志 + 可选的扩音器命令;调用方在apply里也应当只在什么都还没分配的位置做有风险的注册。 - 版本差异只警告:不同版本从来没有单独让插件出过错,按它拒载会把插件从"只是升级了 dsh"的部署里踢出去。两侧的语义要读准:
declared= 本构建编译时对着哪一版(优先构建时烧入的精确版本,见下),runtime= 本产物自己的链接现在解析到哪一版。ok 行因此写dsh links: <包> <版本>并注明"宿主身份未观测",而不是running。 declared优先用烧入版本:构建脚本在链接好那份要编译的 dsh 之后,把逐包精确版本写成产物旁边的lib/dsh-build.json(BUILD_VERSIONS_FILE),运行时用readBuildVersions(new URL('./dsh-build.json', import.meta.url), packages, peer地板)读回。没有这个文件(开发树、单测、老产物)就逐包回落到 peer 区间地板 —— 回落是逐包的,一个写坏的条目不会把整份文件作废。这也让原地升级安装版 dsh 后的那次 WARN 有意义:警告说得出"我编译时是 X,现在链接解析到 Y"。- 绝不抛错:
apply抛出会让 loader entry 失败、一路带到 CLI(process.exit(1)),一次版本漂移不该让整个dsh起不来。拒绝加载的效果一样(引擎一点也没挂上)。
API
| 名字 | 作用 |
|---|---|
| provision(ctx, log, spec) | 一步入口:取证 + 判定 + 打日志,返回 CompatVerdict |
| verdictOf(evidence) / floorOf(range) | 纯判据,零 DSH 依赖,可脱离宿主全分支单测 |
| readBuildVersions(buildUrl, packages, fallback?) / BUILD_VERSIONS_FILE | 读构建时烧入的精确版本,缺失逐包回落到 peer 地板 |
| readDeclaredVersions(manifestUrl, packages) / readRuntimeVersions(packages, base?) | peer 区间地板(回落用)/本产物自己链接解析出的版本 |
| gatherEvidence(ctx, spec) / checkServices / checkInterval | 取证 |
| probeToolsRegistry / probeTypertRegistry | 两个注册表探针(各自可单独用) |
| toolProbeDeclaration(defineTool) | 生成 tools 探针声明:把调用方的 helper 注入进来,免得包里认识 dsh 类型 |
| minimalTypertProbeDeclaration(packageId) | 仅限「contribution 里没有 codec」的场景;默认位置留给调用方的真身 |
| verifyRegisteredFaces / schemaNamesFrom / declaredSchemaKeys | 真身注册后的复查 |
| registerMegaphone / compatReport | 拒绝时留给用户一条能说明原因的命令 |
| COMPAT_PREFIX | 所有日志行的统一前缀(compat:) |
CompatVerdict:load(可否继续)、skipped、status(ok / version-mismatch / version-unknown / probe-skipped / probe-failed)、problems / warnings / notes、lines(可直接喂 logger)、reason。
已知边界
- 版本比较只覆盖「本产物 ↔ 它链接的那份 dsh」,宿主身份未观测 —— 这是设计边界,不是 bug。插件里没有任何办法看到宿主自己的版本(没有哪个 dsh 服务把它暴露给插件),
readRuntimeVersions解析的是本产物自己import.meta.url那条链接指向的包。于是两种形态会打印出"看起来不对"的版本号,且都是正确的:- checkout 宿主 + 安装版链接:宿主是 0.1.6 checkout,而插件的
@deepseek-ai/*链接指向安装版 0.1.5-rc.2 → ok 行打印 0.1.5-rc.2。宿主是否同源不在本包能力内,此时只有真身探针是真的防线(本包正是为此把探针做成"注册你真身声明")。 - 原地升级了安装版 dsh、但没有重建插件:
runtime变成新版本、declared(烧入版本或 peer 地板)还是旧版本 → 一条 WARN,插件照常加载。 两侧都只描述"插件 ↔ 它链接的那份 dsh",ok 行因此写dsh links:并显式注明宿主身份未观测;要判定进程里到底混装了几份 dsh,得用符号探针(TOOL_RUNTIME_SCHEDULER之类的 unique symbol),不是本包。
- checkout 宿主 + 安装版链接:宿主是 0.1.6 checkout,而插件的
- 事件名无法证明:监听器注册在改名后的事件上照样成功、然后永远不跑。所以事件清单只进"记一笔",运维能拿到的信号是版本行与
compat:日志。 - invocation endpoint 不在公开查询面里(
list/listPackages只暴露 schema 与 package model),所以复查覆盖的是 schema key 与工具名。 - 探针误判会把能用的插件变成拒载的插件 —— 这是本包最大的风险,所以两个探针都对着宿主真实的注册表(
ToolRuntime/TypertRegistry)跑常驻测试。
开发
pnpm install
pnpm typecheck # 先链接 DSH peers(需要一份已安装的 dsh)
pnpm test
pnpm release:check # typecheck + build + test + pack(含 tarball 内容断言)
pnpm build && npm publish测试用的 @deepseek-ai/* 全部来自已安装的 dsh(scripts/link-dsh.mjs,--dsh <dir> 可指定非全局安装),仓库里不钉版本:探针的常驻测试就是"在你装的那份 dsh 上仍然工作"。
作为源码依赖被别的仓库引用时(消费方 link:/file: 指向本目录):
pnpm install && pnpm build # 消费方读到的是 lib/,改完源码要重建
node_modules/.bin/tsc -w -p tsconfig.json # 开发底座本身时可以挂着 watch解析走本目录的 realpath,所以 peers 必须在本目录链接过(上面两条命令已包含)。
zod 这个 peer 的区间刻意放宽到 >=4.4.3 <5:本包只用它构造探针 schema,而家族消费方带着两份都能工作的 zod —— 4.4.3(对齐 harness checkout)与 4.6.5(已安装 dsh 自带的)。^4.6.5 会拒掉前者;两端都用 pnpm typecheck && pnpm test 实测过。
License
MIT
