启动装配:Profile、Bundle 与 patch 分层
没有默认配置文件,只有空数组加 patch 层
上一章讲的是"插件怎么活"。这一章讲的是"谁决定装哪些插件、配什么值"——从
dsh web这七个字符,到一棵 160 多行的插件树被挂起来的全过程。涉及源码:
apps/cli/src/(879 行)、packages/boot/app-boot/src/、vendor/include/src/index.ts(377 行)、vendor/loader/src/、packages/bundle/*/cordis.patch.yml(base 451 行 / web-app 445 行 / headless 35 行)。
0. 先说结论
dsh 的启动配置有一个反直觉的设计:没有"默认配置文件"这种东西。
一般 Agent 框架的做法是:内置一份 default.yml,用户写 config.yml 覆盖它,代码里做深合并(deep merge)。dsh 全都不这么干:
| 一般做法 | dsh 的做法 |
|---|---|
| 内置默认配置文件 | 空数组 []——profile 的根配置永远是空的 |
| 用户配置深合并覆盖默认值 | 所有内容都是 patch 层,一层层叠上去 |
| 合并到字段粒度 | 整个 config 对象替换,不做深合并 |
默认值散落在各插件的 ?? default 里 |
每个值都在某一层 patch 里显式写出 |
于是"这次运行到底装了什么、每个值是多少"这个问题有一个确定答案:把所有 patch 层按顺序应用到空数组上,得到的行列表就是全部真相。而且这个"应用"只有一个实现(applyEntryPatches,70 行),启动和 --dump-config 共用它,所以 dump 出来的东西不可能和真正启动的不一致。
本章的主线:
dsh web
→ args.ts 解析出 { mode: 'profile', profile: 'web', patches: [], args: [] }
→ profile-boot 找到 $DSH_HOME/profiles/web/,读它的 package.json
→ loadProfile dsh.profile.bundles = [dsh-base, dsh-web-app] → 解析出 2 个 bundle 层
→ composeProfile 叠层:bundle 层 → profile 层 → home 层 → --patch → 遥测开关
→ boot() new Context() → 装 Loader → include 空根 cordis.yml + 全部 patch
→ applyEntryPatches 空数组 + 全部 patch = 最终行列表
→ 每行一个 Fiber !!js 在各自 fiber 里求值 → 依赖齐了才激活(第 2 章)
→ assertEntriesActivated 有行没激活起来?大声失败,不静默降级
→ watchUserPatches 两个 watcher 盯着两个用户 patch 文件,改一行热生效
全章图示:assets/ch03-patch-layers.svg
1. Profile:一个目录就是一个产品实例
Profile 是 dsh 的产品单位。dsh --profile web 和 dsh --profile headless 不是"同一个程序的两种模式",而是两棵不同的插件树:web 那棵有 HTTP 服务器、浏览器前端 roster、Host 层;headless 那棵一个都没有,只有一个读命令行参数、跑一次任务、打印结果的 runner。
一个 profile 就是 $DSH_HOME/profiles/<name>/ 下的一个目录(packages/boot/app-boot/src/profile.ts:104),里面有四个文件:
| 文件 | 作用 |
|---|---|
package.json |
两件事:dsh.profile.bundles 有序层列表;dependencies 装外部插件 |
cordis.patch.yml |
用户自己的 patch 层 |
pnpm-workspace.yaml |
给外部插件用的 pnpm 设置 |
cordis.yml |
永远是空数组,只是给 Loader 一个锚点(见第 7 节) |
initProfile()(profile.ts:152)第一次用到时生成它们,而且已存在的文件绝不覆盖,所以重复初始化是幂等的:
export function initProfile(dir: string, bundles: readonly string[]): void {
mkdirSync(dir, { recursive: true })
const manifestPath = join(dir, 'package.json')
if (!existsSync(manifestPath)) {
const manifest: ProfileManifest & { private: boolean } = {
name: `dsh-profile-${basename(dir)}`,
private: true,
dependencies: {},
dsh: { profile: { bundles: [...bundles] } },
}
writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
const workspacePath = join(dir, 'pnpm-workspace.yaml')
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE)
}
只有两个 profile 自带模板、能首次使用时自动初始化(profile.ts:114):
export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
}
其他名字必须先用 dsh plugin --profile <name> add <package> 建,loadProfile() 遇到不存在且无模板的 profile 会直接抛错并把建法写在错误信息里(profile.ts:371):
throw new Error(
`${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
)
这是 dsh 的一条通用规范在配置层的体现——AGENTS.md 里写作 "Misconfiguration fails loud":宁可启动不了,也不要静默降级成一棵不完整的树。后面第 8 节还会看到它更狠的一次应用。
profile 名字的校验
if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..'
// The launcher-maintained flat module fallback lives at this sibling path.
|| name === 'node_modules') {
throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`)
}
前几个是防目录穿越,最后一个 node_modules 是个真实的坑:$DSH_HOME/profiles/node_modules 被启动器占用了——它是一个扁平的模块回退目录,每个 dsh 安装自带的包在里面有一个符号链接(healProfilesModuleFallback,profile.ts:223)。
为什么需要它?因为插件模块解析是"双锚点"的:一个 bundle 名先从 dsh 安装目录解析,再从 profile 目录解析。而 Loader 的 baseUrl 是 profile 目录——用户用 dsh plugin add 装的外部插件在 profiles/<name>/node_modules 里,它们 import '@deepseek-ai/cordis' 时必须命中安装自带的那一份 cordis,否则就会加载出第二个 cordis 实例,两套 Context 类型互不相认。Node 的父目录上溯(parent-walk)正好会走到 profiles/node_modules,符号链接把它接回安装目录。配套的 pnpm 设置也是为这个服务的(profile.ts:138):
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
hoisted + 不自动装 peer,让外部插件的 peer 依赖"缺失",从而穿透到上面那层回退目录去——故意制造缺失,来强制共享单例。
2. Bundle:一个 npm 包只导出一个 YAML
Bundle 是可安装的 patch 层。它的 API 是——没有 API。packages/bundle/base/package.json 里全部的声明是:
{
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
loadProfile() 只读这一个字段(profile.ts:371):
const declared = bundleManifest.dsh?.bundle?.patch
if (declared === undefined) {
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
}
注意这里也是 fail loud:把一个不是 bundle 的包写进 bundles 列表,不会被当成"这层没有 patch"而跳过,而是报错。dsh 官方 README 把这个契约写得很明白:
The package has no runtime API; the profile composer resolves the patch through the
dsh.bundle.patchmanifest field, never through code.
三个自带 bundle 的规模:
| Bundle | 行数 | 行(row)数 | 干什么 |
|---|---|---|---|
dsh-base |
451 | 78 | 所有 profile 的第一层:模型适配器、工具、持久化、沙箱策略、设置/凭据、遥测、子代理 provider |
dsh-web-app |
445 | 84 | 浏览器界面:HTTP 服务、Host 层、前端 roster、Web 专属的值 |
dsh-headless |
35 | 6 | 一次性任务模式:只加 code-runtime、startup、runner 三行,改两行、关一行 |
headless 那 35 行值得整个贴出来看——它是"用 patch 定义一个新产品形态"的最小样本:
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
# The shared module-reload HMR row stays off; the launcher's watch-only
# fallback still keeps the user patch layers live until the run exits.
- id: hmr
disabled: true
- id: tools
config:
mode: !!js process.env.DSH_TOOLS_MODE
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker-thread'
- id: headless-startup
name: '@deepseek-ai/dsh-headless/startup'
# Reads its task from the ordinary headlessStartup provider.
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.headlessStartup.task
一个完整的"无界面 Agent 产品"= 三行改写 + 三行新增。剩下的 78 行能力全部从 base 层继承。这就是"Everything is a Plugin"在产品层面的兑现方式。
最后一行还顺手示范了patch 层里的依赖声明:inject: [headlessStartup] 让这一行的 Fiber 等到 headlessStartup 服务出现才激活,于是 !!js ctx.headlessStartup.task 求值时它一定已经在了——这就是第 2 章 epoch 机制的直接应用,只不过写在 YAML 里而不是 static inject 里。
3. patch 算法:70 行决定一切
所有分层最终都收敛到一个函数:applyEntryPatches(vendor/include/src/index.ts:58)。它的 JSDoc 第一句就自称 "THE patch semantics of this include"——挂载时和 --dump-config 时都调它,所以两者不可能漂移。
完整算法:
export function applyEntryPatches(
data: EntryOptions[],
patches: PatchOptions[] | undefined,
warn: (message: string, ...args: any[]) => void,
): EntryOptions[] {
data = structuredClone(data)
if (!patches?.length) return data
const entryMap = new Map<string, EntryOptions>()
const buildMap = (entries: EntryOptions[]) => {
for (const entry of entries) {
if (entry.id) entryMap.set(entry.id, entry)
if (entry.group && Array.isArray(entry.config)) {
buildMap(entry.config)
}
}
}
buildMap(data)
for (const patch of patches) {
const { id, insert, name, ...overrides } = patch
if (insert) {
// ...插入到 data 末尾,或插入到 id 指定的 group 里
buildMap(insert) // ← 关键:索引新插入的行
continue
}
if (!id) { warn('patch: id is required for non-insert patches'); continue }
const target = entryMap.get(id)
if (!target) { warn('patch: entry %C not found', id); continue }
if (name && name !== target.name) {
warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name)
continue
}
for (const [key, value] of Object.entries(overrides)) {
if (key === 'id') continue
target[key] = value
}
}
return data
}
只有两种操作:
① insert:把行加进去。不带 id 就追加到顶层列表末尾;带 id 就追加到那个 group 行的 config 数组里(不是 group 会 warn 跳过)。
② id 定位覆写:{ id, ...overrides },把 overrides 里的每个键整个赋给目标行。
第二点是全章最需要记牢的一句话:
A patch replaces the targeted row's whole
config, so each row below restates every key it owns.(packages/bundle/web-app/cordis.patch.yml开头的注释)
target[key] = value 是赋值,不是合并。所以 web-app 层要改 session-query-sqlite 的 openAt,必须把 path 一起重写:
- id: session-query-sqlite
config:
path: ':memory:'
openAt: never
这个"缺陷"在 base bundle 的 README 里被明确列为已知限制:
A patch replaces whole row configs — profile overrides must restate every field a row keeps; there is no deep-merge layer.
为什么明知是麻烦还要这样设计? base bundle 的 patch 文件头部给了答案:
# A patch replaces the targeted row's whole `config` rather than merging into
# it, so a row whose value differs by mode does NOT live here: it belongs to
# each mode bundle, keeping any single row down to one bundle layer plus the
# user's.
深合并的代价是:一个字段的最终值可能来自四五层的叠加,你必须在脑子里跑一遍合并算法才知道它是多少;而且"删除一个字段"在深合并里几乎无法表达。整体替换则保证:任何一行的 config,你能在某一个文件里读到它完整的样子。代价转嫁成一条内容约定——按模式变化的值不写进 base,各 mode bundle 各自重述完整配置,于是任何一行最多只经过"一个 bundle 层 + 用户层"两次写。
三个容易忽略的细节
structuredClone 在第一行,而且注释说明就算没有 patch 也要克隆:
The input is never mutated and the result is always detached from it (even with no
patches): patching or mounting shared entry objects would bake earlier values into
the cached parse, so repeated application (config hot-reloads) could never revert
a removed or changed patch.
——热重载会反复对同一份解析结果应用不同的 patch 集。如果第一次应用时把值写进了共享对象,第二次删掉那条 patch 也回不去了。这类"可逆性"关注和第 2 章的 effect disposer 是同一种思路,只是搬到了数据层。
buildMap(insert) 让插入的行可被后续层寻址。注释写了这是补上的行为:
Index what this patch added so a LATER patch in the same list can target it.
[...] without this, inserted rows were silently unpatchable.
没有这一行,dsh-base 插入的 78 行对 dsh-web-app 层就是不可见的,整个分层机制直接失效。
patch 打空了只 warn,不报错。这看起来违反"fail loud",但恰恰是分层机制必需的:base 层的遥测行可能因平台被禁用、某个 profile 可能压根没装某个插件,此时用户层里针对它的 patch 命中不了——把这个当致命错误,会让任何一份 cordis.patch.yml 都绑死在特定 bundle 组合上。启动器自己也依赖这个宽松语义,见下一节的遥测开关。
4. 层序:谁压得过谁
composeProfile()(apps/cli/src/profile-boot.ts:142)负责把层排好序,allPatches()(:122)给出确切顺序:
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [
...composed.bundlePatches, // ① 每个 bundle 层,按 dsh.profile.bundles 顺序
...composed.profile.patches, // ② profile 自己的 cordis.patch.yml
...composed.homePatches, // ③ $DSH_HOME/cordis.patch.yml
...composed.overlays, // ④ --patch 覆盖 + agent-presets 根 + 遥测开关
]
}
后面的压前面的(同一行的同一个键,最后一次写生效)。四层的分工:
| 层 | 归属 | 典型内容 |
|---|---|---|
| bundle 层 | 产品/发行方 | 装什么插件、每个插件的默认配置 |
| profile 层 | 用户,针对这一个 profile | "我的 web profile 要开全文搜索" |
| home 层 | 用户,这台机器的所有 profile | "这台机器上一律用我自己的模型端点" |
| overlay 层 | 单次调用 / 启动器 | --patch ./debug.yml、遥测开关 |
home 层压过 profile 层,源码注释解释了理由:
the home-level user layer ($DSH_HOME/cordis.patch.yml — machine-local
preferences that apply to every profile, so it outranks the per-profile layer)
"作用范围更大的层反而优先级更高"和常见的 CSS/配置直觉相反。这里的取舍是:home 层表达的是这台机器的物理事实(我的代理、我的凭据路径、我这台机器没有 pwsh),而 profile 层表达的是"这个产品实例想怎么跑"。物理事实赢。
启动器自己也用 patch 说话
composeProfile 的最后两件事很能说明"patch 是唯一的配置手段"这个立场彻底到什么程度——连启动器改配置也不走代码路径,而是往层里追加一条 patch。
一是 agent-presets 的 shipped 根目录,只有这个 app 自己知道它的位置(profile-boot.ts:159):
if (rows.has('agent-presets')) {
composedOverlays.push({
id: 'agent-presets',
config: {
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }],
},
})
}
注意 ...(rows.get('agent-presets')?.config ?? {})——因为 patch 是整体替换,启动器必须自己把已有 config 展开重述。整体替换语义的成本在这里付得很直白。
二是遥测开关(profile-boot.ts:80):
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '' || !hasRow) return undefined
return { id: TELEMETRY_ROW_ID, disabled: true }
}
两个细节:
- 任何非空值都算关闭,包括
'0'和'false'。JSDoc 给的理由:"a privacy switch prefers off-by-mistake over on-by-mistake"——隐私开关的两种误判不对称,宁可误关。 - 组合里没有遥测行,就不生成这条 patch(
hasRow为假)。这就是上一节那个"patch 打空只 warn"的宽松语义在被主动利用:自定义 profile 不装遥测,也能在设了这个环境变量的机器上正常跑,而不是启动时冒出一条无害但吓人的告警。
还有一点:这里用的是 disabled: true 而不是 config: { mode: 'DISABLED' }。base bundle 的注释解释了区别——config 只能让插件"跑起来但什么都不做",disabled 是根本不加载这一行:"the launchers patch the row disabled; config cannot disable a row"。安全/隐私开关要的是后者。
5. !!js:延迟到行自己的 fiber 里求值
patch 里到处是 !!js:
- id: sandbox-policy
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
- id: session-persistence-jsonl
config:
root: !!js dshHomePath('sessions')
- id: bash-sandbox
disabled: !!js process.platform === 'win32'
这套机制横跨三个文件,链条值得完整走一遍。
① 解析:变成一个普通对象,不求值(vendor/include/src/index.ts:9)
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: (data) => typeof data === 'string',
construct: (data) => ({ __jsExpr: data }),
predicate: isJsExpr,
represent: (data) => data['__jsExpr'],
})
export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)
!!js foo 解析成 { __jsExpr: 'foo' }。而且带 represent,所以能原样写回 YAML——配置回写不会把表达式烧成某次求值的结果。
② 求值:在行自己的 fiber 里,通过一个 waterfall(vendor/loader/src/index.ts:92)
ctx.on('internal/config', function (this: Fiber, _config, next) {
const config = next()
if (!this.entry || this.parent.fiber?.entry === this.entry) return config
// Tree carriers (Group, Include) keep their configs literal: their
// entry and patch lists hold other rows' configs, whose `!!js`
// expressions belong to those rows' own fibers.
const plugin = this.runtime?.callback as Record<PropertyKey, unknown> | undefined
if (plugin?.[EntryGroup.key]) return config
return interpolate(this.ctx, config)
}, { global: true })
三件事同时发生:
- 它是第 2 章讲的 waterfall 监听器,先
next()拿到内层结果再加工——所以别的插件可以插在它之前或之后改配置; { global: true }绕过 context filter,所有 fiber 的配置解析都经过它;- tree carrier(Group、Include)直接返回字面值。这一点很关键:Include 的 config 里装的是别人的行和 patch,那些
!!js属于那些行,不属于 Include。如果在这里求值,!!js ctx.headlessStartup.task会在 headlessStartup 还不存在的时候炸掉。
③ 求值上下文:with (ctx)(vendor/loader/src/config/utils.ts:5)
export const evaluate = new Function('ctx', 'expr', `
with (ctx) {
return eval(expr)
}
`)
export function interpolate(ctx: object, value: any) {
if (isJsExpr(value)) return evaluate(ctx, value.__jsExpr)
else if (!value || typeof value !== 'object') return value
else if (Array.isArray(value)) return value.map(item => interpolate(ctx, item))
else return valueMap(value, item => interpolate(ctx, item))
}
with (ctx) 让 ctx 上的一切都进作用域。所以 !!js ctx.headlessStartup.task 能写,!!js dshHomePath('sessions') 也能写——后者是 boot() 在装 Loader 之前挂上去的(packages/boot/app-boot/src/index.ts:770):
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
把这三步串起来,就得到了 !!js 真正的语义:它不是"配置文件里的模板变量",而是"在这一行的插件即将激活的那一刻、在这一行自己的 Context 作用域里执行的一段表达式"。因为求值时机跟着 fiber 走,第 2 章的 epoch 机制自动帮它保证了依赖顺序——这就是 headless 那六行能写 !!js ctx.headlessStartup.task 的全部原因。
顺带一个安全边界:dsh 的 AGENTS.md 规定 !!js(两个感叹号,不是 !js)只允许出现在插件 config 和 entry 的 disabled 下,其他元数据保持字面。这是有意的限制——插件身份(name、id)不能是算出来的,否则"这次到底装了哪些插件"就不再是静态可读的事实了。
6. 为什么根配置每次启动都要重写成空数组
profile-boot.ts:60 有一段看起来多余的常量:
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
而 prepareProfile() 每次启动都无条件重写这个文件(:98):
export function prepareProfile(name: string, userLayer = true): Profile {
healProfilesModuleFallback(INSTALL_ANCHOR)
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
return profile
}
为什么不是"不存在才写"?JSDoc 交代得很清楚,这是在防一个真实存在的自我污染:
The root is always rewritten: the whole composition is patch layers, and the
vendored Loader's tree write-back (a plugin self-disposing persists the current
tree) can bake composed rows into this file — which would duplicate every bundle
insert on the next boot.
链条是这样的:Loader 支持配置回写(Include.write(),vendor/include/src/index.ts:371),插件自我卸载时会把当前树持久化回文件。而当前树是"空根 + 所有 patch 应用后"的结果,一旦写回 cordis.yml,下次启动就变成"78 行的根 + 再叠一遍 78 行的 insert"——每个插件装两遍。每次启动都推平根文件,是对这条路径最简单的免疫。
那这个文件为什么还要存在?
The file exists on disk only because the Loader needs a real include root to
anchor `baseUrl` at the profile directory (the config dump anchors on the same
file, so both compose over the identical base).
——只为了给 Loader 一个真实路径来锚定 baseUrl(外部插件的模块解析要从 profile 目录出发,见第 1 节)。它是一个纯粹的坐标原点,不承载任何配置。
7. 启动:从 new Context() 到"每一行都活着"
boot()(packages/boot/app-boot/src/index.ts:757)是启动的收口:
const ctx = new Context()
let stage = 'host preparation failed'
try {
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
await prepare?.(ctx)
stage = 'plugin tree failed to load'
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
await ctx.get('loader')?.await()
if (ctx.get('loader') === undefined) return ctx
await assertEntriesActivated(ctx, binName)
return ctx
} catch (cause) {
await ctx.fiber.dispose()
// ...
}
prepare 回调是启动器往树里塞"启动期事实"的地方(profile-boot.ts:248):
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
app.current = hostCtx
// Before any config-tree entry mounts, so plugins resolve all launch-time
// environment values from the same immutable provenance snapshot.
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
provideCmdline(hostCtx, {
args: options.args,
exit: code => void shutdown.shutdown(code),
})
})
两个关键设计:
环境快照是冻结的、单一来源的。 不是让各插件自己读 process.env,而是启动前拍一张不可变快照,所有插件从同一份"出处"(provenance)解析启动期环境值。这样"这次运行的环境是什么"是一个可审计的事实,而不是散布在几十个 process.env.X ?? default 里的推断。
命令行参数不属于启动器。 args.ts 只解析自己那几个 flag,第一个它不认识的 token 开始就全部交给树:
dsh --profile web --port 8080 # --port 属于 web app
dsh --profile web --help # web app 的 help,不是启动器的
dsh --help # 启动器自己的 help
参数经 ctx.cmdlineArgs 变成一个不可变快照,任何注入了它的 app 插件都能解析自己那一族 flag、打印自己的 --help。于是"加一个新的产品形态"不需要改启动器一行代码——headless 的 6 行 patch 就是证明。
最狠的一道门:assertEntriesActivated
第 2 章讲过,Cordis 里 PENDING(依赖没齐,插件一行没跑)是完全正常的状态——这正是热插拔的基础。但对一个已经启动完的产品来说,"配置里写了这一行,但它至今没激活"几乎总是配置错误。dsh 于是在启动收尾处加了一道审计(app-boot/src/index.ts:692):
for (const entry of ctx.loader.entries()) {
const fiber = entry.fiber
if (fiber === undefined || entry.disabled) continue
const state = fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_FAILED) {
try {
await fiber.await()
} catch (error) {
rejectionReasons.push(error)
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
}
continue
}
if (state === FIBER_PENDING) {
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
const subject = missing.length === 1 ? 'service' : 'services'
failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) { /* 抛出,附上每一行的诊断 */ }
它把 Cordis 那个宽容的生命周期模型,在产品边界上收紧成一条硬约束,而且诊断信息直接告诉你缺哪个服务(waiting for services: fs, shell)。这是本章最值得抄走的一招:框架层要宽容(才能热插拔),产品层要严格(才能可诊断),两者不矛盾,只需要在启动收尾处加一次审计。
FAILED 分支里还有个细节:它 await fiber.await() 只为了拿回那个 rejection 的原始 stack,再把最深层的 cause 的 stack 追加到最终错误信息里:
let deepest: unknown = cause
while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
因为 Loader 的事务性挂载会给每一层树包一层错误信息,不挖到底你只能看到一串包装消息,看不到真正炸的那行代码。
8. 热更新:改一行 YAML,不重启
长驻界面(web)上,两个用户 patch 文件都是热的(profile-boot.ts:285):
await watchUserPatches(ctx, { binName: NAME, filename: composed.profile.patchPath, compose: composeLive })
await watchUserPatches(ctx, { binName: NAME, filename: homePatchPath(), compose: composeLive })
composeLive() 每次重新组装整个层栈(:240):
const composeLive = (): PatchOptions[] => structuredClone([
...composed.bundlePatches,
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
...composed.overlays,
])
四个不那么显然的决定,注释都给了原因:
① bundle 层在下、overlay 在上,所以用户编辑永远挤不掉它们。 热重载不是"打一个增量补丁",而是重新走一遍完整的分层——层序是配置模型的一部分,不能因为触发路径不同而变化。
② 两个文件都整份重读,哪怕 HMR 只告诉你其中一个变了。 理由:the HMR watcher hands us only the changed file's patches, which one of the reads duplicates — fresh reads keep the two watchers from stitching in each other's stale copy。两个 watcher 各自持有对方的旧副本,是这类"多 watcher 拼装同一份状态"的经典 bug。
③ 每代都 structuredClone。 理由和第 3 节同源,但更尖锐:
the include pushes `insert` rows into the mounted tree BY REFERENCE and later
id-targeted patches mutate those objects in place. Reusing one parsed patch
object across applications would bake a user override into the bundle's
in-memory insert row, so removing the override could never revert the row to
the bundle default.
——删掉自己刚加的一行覆盖,值回不去默认。这是配置热重载里最难查的一类 bug("我删了那行怎么还生效?重启就好了"),dsh 用两处 structuredClone 从根上堵掉。
④ 组合里没有 HMR 服务时,临时装一个"只看文件不重载模块"的实例(:279):
if (ctx.get('hmr') === undefined) {
if (ctx.get('timer') === undefined) {
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
}
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
}
web/headless 两个 bundle 都把共享的 hmr 行关掉了(模块级热重载的卸载路径未经充分测试),但 cordis.patch.yml 的热生效是对用户承诺过的行为。启动器于是补一个 root: [] 的 HMR——不监视任何模块目录,只做文件监视。注释里那句 A silent skip would break the documented hot-reload contract 是这类"承诺过的行为不能因为内部重构悄悄消失"的教科书写法。
顺着 watchUserPatches 再往下看一层,还有一个第 2 章遗留概念的实战(app-boot/src/index.ts:255):
if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
INACTIVE_EFFECT 就是第 2 章讲的"在已经开始卸载的 fiber 上创建 effect"。什么时候会遇到?一次性任务跑得比 watcher 建立还快,整棵树已经在拆了。这不是错误,是"app 完全按要求退出了",所以返回一个空 disposer 而不是崩掉。同一段代码在 runProfile 里还有一次对称处理(suppressShutdownError,profile-boot.ts:195)。
9. --dump-config:不会漂移的自省
想知道这次到底装了什么,不用启动:
dsh --profile web --dump-config # 完整组合
dsh --profile web --dump-default-config # 只有 bundle 层
runDumpConfig(apps/cli/src/dump-config.ts)把每一层连着它的来源文件名一起打印,并且锚定同一个空根文件:
const loaded = prepareProfile(profile, !defaultOnly)
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
label: layer.packageName,
patches: layer.patches,
}))
// ...profile 层、home 层、每个 --patch 覆盖各自成一层
process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers))
它可靠的原因写在模块 JSDoc 里:compose the profile's patch layers through the include plugin's patch algorithm without booting or evaluating !!js——共用 applyEntryPatches,只是不启动、不求值 !!js。composeEntries()(profile.ts:413)也是同一个入口,注释说得直白:the same single applyEntryPatches call the boot include makes, so flag derivation and config dumps see exactly what mounts。
--dump-default-config 还有个专门用途:它完全不解析用户层(prepareProfile(profile, false) → loadProfile(..., { userLayer: false }))。所以当你把 cordis.patch.yml 写坏、启动直接失败时,这条命令仍然能跑——它是配置坏掉时的恢复诊断工具,这也是为什么 loadProfile 要专门开一个 userLayer 开关。
10. 一份 patch,两套 shell 栈
平台差异是分层机制最能打的一个实战场景。base bundle 里:
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
config:
timeoutMs: 60000
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
disabled: !!js process.platform !== 'win32'
tool-bash / tool-pwsh 同样成对门控。一份 patch 文件,每台主机上恰好一套 shell 栈(README:one shared patch file, exactly one shell stack per host)。
这里藏着一个第 8、9 章会展开、但现在就能看见形状的事实:换 shell provider 不影响权限模型。Windows 上 sandbox / sandbox-policy 照样强制文件效果策略(走 dsh-sandbox-local → dsh-sandbox-windows-acl 的受限令牌运行器),审批服务照跑,fs-sandbox 照样拦 ctx.fs 写入。这正是第 2 章 epoch 机制加上第 8 章 capability seam 的联合效果:consumer 认的是服务名,不是实现。
base README 还给了一条很有教育意义的告警——如果 Windows 用户想换回 bash:
the bash-restore recipe must be complete: disable
pwsh-sandbox/tool-pwshAND re-enablebash-sandbox/tool-bash— both executor families register the samebashservice, so an incomplete recipe fails loud at load
只做一半会怎样?两个 executor 家族注册同一个 bash 服务 → 重复注册 → 加载时大声失败。这是"注册即 effect"的一个副产品:服务注册冲突在加载期就暴露,而不是运行到某次工具调用时才出现诡异行为。同一条规则在 README 里还有一个例子:Windows 上 fs-sandbox 已经在管 ctx.fs,再挂 dsh-fs-local 会双重注册 ctx.fs,同样 fail loud。
11. 这套设计的代价
诚实地列一下:
① 没有深合并,用户覆写要重述整块 config。 想改 session-query-sqlite 的 openAt,得把 path 一起写。行的 config 一大,这就很烦,而且 bundle 升级后新增的字段不会自动流到你的覆写层里——你的覆写会把它顶掉。
② 层数多,"这个值从哪来"要跑 dump 才知道。 四层 + 启动器追加,虽然 --dump-config 会标注来源,但比"就一个 config.yml"确实重。
③ 行 id 是隐式公共 API。 用户 patch 靠 id 定位,所以重命名一个 row id 会静默地让用户的 patch 打空(只 warn)。当前处于 developer preview 的 foundation over blast radius 阶段可以接受,正式发布后这就是兼容性负担。
④ !!js 是 eval。 with (ctx) { return eval(expr) } 无沙箱。它读的是本机文件(bundle 自带 + 用户自己写的),信任模型上等价于"能改配置文件的人本来就能执行代码",但这条边界必须清楚:不要从不可信来源接收 patch 文件。
⑤ 启动期的严格性有代价。 assertEntriesActivated 会因为"某行在等一个永远不会来的服务"而拒绝启动整个产品。诊断友好,但也意味着一个可选功能的配置错误会让整个 app 起不来——这是 dsh 主动选的一边。
12. 可迁移的经验
抛开 dsh 和 Cordis,这一章有五条可以直接搬到任何项目的做法:
① 配置的"默认值"应该是数据,不是代码。 把默认值从各处的 ?? default 里收回来,放进一个显式的、可读的默认层。"这个值是多少"就变成了读文件,而不是读代码。
② 分层的应用算法要只有一份实现。 dsh 让启动和 --dump-config 共用 applyEntryPatches——dump 不可能和真实启动不一致。凡是"自省工具另写了一遍逻辑"的项目,最后都会碰上 dump 和现实不符的调试噩梦。
③ 每次重新组装,而不是打增量补丁。 热重载重跑整个分层,比"算出 diff 再 apply"简单得多,也不可能因为漏算一个 diff 而进入不一致状态。
④ 共享的配置对象一定要克隆。 这是本章重复出现两次的教训:insert 的行是按引用推进树的,后续 patch 原地改它们;复用同一份解析结果,就会让"删掉覆盖"永久失效。
⑤ 框架宽容、产品严格。 生命周期模型该允许"等依赖"这种中间态(否则没法热插拔),但产品启动完成时必须审计一次并大声失败。这条对任何有插件系统的项目都成立。
附:本章源码引用表
| 位置 | 内容 |
|---|---|
apps/cli/src/args.ts:1 |
启动器命令语法与"第一个不认识的 token 起交给 app"的契约 |
apps/cli/src/bin.ts:30 |
四种 invocation 的分派,按需 import runner |
apps/cli/src/dump-config.ts:30 |
runDumpConfig():按层打印,锚定同一个空根 |
apps/cli/src/profile-boot.ts:60 |
PROFILE_ROOT_CONFIG:永远是 [] 的根 |
apps/cli/src/profile-boot.ts:80 |
resolveTelemetryPatch():任何非空值都关闭 |
apps/cli/src/profile-boot.ts:98 |
prepareProfile():每次启动重写根文件 |
apps/cli/src/profile-boot.ts:122 |
allPatches():四层的确切顺序 |
apps/cli/src/profile-boot.ts:142 |
composeProfile():层组装 + 启动器追加的两条 patch |
apps/cli/src/profile-boot.ts:195 |
suppressShutdownError():正常退出不是失败 |
apps/cli/src/profile-boot.ts:207 |
runProfile():信号、fail-loud、boot、watcher |
apps/cli/src/profile-boot.ts:240 |
composeLive():热重载的重新组装 + structuredClone |
apps/cli/src/profile-boot.ts:279 |
HMR 缺失时的 watch-only 兜底 |
packages/boot/app-boot/src/index.ts:232 |
watchUserPatches():INACTIVE_EFFECT 的处理 |
packages/boot/app-boot/src/index.ts:278 |
loadOptionalPatches():缺文件=无层,坏文件=报错 |
packages/boot/app-boot/src/index.ts:298 |
loadOverlayPatches():点名的文件缺失即报错 |
packages/boot/app-boot/src/index.ts:692 |
assertEntriesActivated():产品边界的严格审计 |
packages/boot/app-boot/src/index.ts:757 |
boot():两个失败阶段标签 + 挖最深 cause |
packages/boot/app-boot/src/index.ts:770 |
ctx.provide('dshHomePath', ...):!!js 的作用域来源 |
packages/boot/app-boot/src/profile.ts:104 |
resolveProfileDir():名字校验与 node_modules 保留名 |
packages/boot/app-boot/src/profile.ts:114 |
PROFILE_TEMPLATES:仅 web / headless 自动初始化 |
packages/boot/app-boot/src/profile.ts:138 |
hoisted + autoInstallPeers: false:故意制造缺失以共享单例 |
packages/boot/app-boot/src/profile.ts:152 |
initProfile():幂等初始化 |
packages/boot/app-boot/src/profile.ts:223 |
healProfilesModuleFallback():扁平模块回退目录 |
packages/boot/app-boot/src/profile.ts:371 |
loadProfile():bundle 解析与两处 fail loud |
packages/boot/app-boot/src/profile.ts:413 |
composeEntries():与启动共用的同一次 applyEntryPatches |
vendor/include/src/index.ts:9 |
!!js 的 YAML 类型:可原样写回 |
vendor/include/src/index.ts:58 |
applyEntryPatches():THE patch semantics |
vendor/include/src/index.ts:145 |
PatchOptions:patch 能改哪些字段 |
vendor/include/src/index.ts:225 |
enqueue():并发 apply 的串行化 |
vendor/include/src/index.ts:371 |
write():配置回写——第 6 节要防的那条路径 |
vendor/loader/src/index.ts:92 |
internal/config waterfall:!!js 求值时机与 tree carrier 例外 |
vendor/loader/src/config/utils.ts:5 |
evaluate():with (ctx) { eval(expr) } |
packages/bundle/base/cordis.patch.yml |
78 行的共享核心;平台门控;遥测默认关 |
packages/bundle/web-app/cordis.patch.yml |
84 行的浏览器界面层 |
packages/bundle/headless/cordis.patch.yml |
35 行 = 一个完整的无界面产品形态 |
官方对应文档:apps/cli/README.md、apps/cli/reference/README.md、apps/cli/composition.md(生成的组合图)、packages/bundle/*/README.md、docs/cordis-primer.md#loader-configuration。