大家好,我是郑同学。上一篇拆完 Cordis,文尾留了一个问题:那棵树本身是怎么叠出来的。先看两个本机实跑的命令:
$ cat ~/.dsh/profiles/web/cordis.yml # web 这份 profile 的根文件
[] # 内容:一个空清单
$ dsh web --dump-config | grep -c '^- id:' # 实际要启动的完整清单
135
空文件怎么长出 135 行:由四摞补丁叠出——组合包(bundle)层 78 行、web 应用层 57 行、你的层 0、临时层 0,叠放顺序固定,后写胜出。本篇沿组装线走七站,先给一句口诀作为全文总纲,每站可对照:
补丁四层摞,后写盖前写;行是数据,树认 id;冷热一条线,dump 是干跑
图:dsh 组装线全数据流转——从盘上四个文件到 135 行在岗清单,每步真实输入输出
● ● ●
第 1 站:四层的出身——从盘上收齐一摞补丁
组装线的输入在盘上。web 这份 profile 的目录(真实文件,四项):
~/.dsh/profiles/web/
├── package.json ← 声明叠哪些 bundle
├── cordis.yml ← 根文件:空清单 [](开头见过)
├── cordis.patch.yml ← 你的层:现在是空的 []
└── pnpm-workspace.yaml ← 装插件时 pnpm 用
四摞补丁的来源与缺失行为(composeProfile,profile-boot lib:166):
dsh.profile.bundles 逐包解析 | ||
--patch |
两类读法的边界是明确的:bundle 的补丁文件由 package.json 显式声明,属于必需文件,缺失即启动失败;home 层与根文件是可选文件,缺失时按空层处理。这与上一篇的 fail-loud 原则一致:能确定为配置错误的,启动时直接报出,不静默跳过。
收齐后由 allPatches 按固定顺序叠放:bundle 层在下,依次是你的层、home 层、临时层(profile-boot lib:147)。composeProfile 会先干跑一遍合并、建立 id 索引,据此决定是否追加两张自动补丁(预设目录、遥测开关);这一步只做合并计算,不启动插件,第 7 站的 dump 复用的是同一个函数。
本站输入 → 输出
输入 ~/.dsh/profiles/web/ 的四个文件,其中 package.json
声明 bundles = [@deepseek-ai/dsh-base, @deepseek-ai/dsh-web-app]
输出 allPatches 的有序补丁数组:
[ base 78 条, web-app 57 条, 你的 0 条, 临时 0 条 ]
本站完整链路
图:第 1 站完整链路——盘上四个文件收齐成一摞有序补丁
● ● ●
第 2 站:空根文件——include 服务把文件变成树
四摞补丁收齐后交给 boot:挂一根 include 行,行内携带根文件路径与未合并的四摞补丁(app-boot lib:964)。接收方 include 服务是一个由文件供货的树:行列表不来自代码,而来自读文件加叠补丁。
初始化做三件事(include lib:203):读根文件(空清单 [])、解析成行列表、连同补丁一并上树。读文件前先做内容比对:内容与上次一致即返回 undefined,解析不再发生,热重载的每次触发都先经过这道检查。
include 内部有一个三行的串行队列 enqueue(include lib:157)。树的差异更新不可重入:初始化的上树与文件监听触发的刷新并发时,建行与回滚会在同一批行上交错,更新挂起。enqueue 把所有更新排进同一条 Promise 链,前一个执行完(无论成败)后一个才开始,保证树的状态串行变化。
本站输入 → 输出
输入 根文件 cordis.yml:原文 '[]',解析出行列表 data = []
输出 apply(candidate):携带第 1 站的补丁摞,
先合并(第 3 站)、再上树(第 4 站)
本站完整链路
图:第 2 站完整链路——根行挂载到 include 服务的初始化
● ● ●
第 3 站:合并算法——applyEntryPatches 逐行
本站处理合并:根文件的 [] 加四摞补丁,产出 135 行。合并函数 applyEntryPatches 上一篇第 2 站贴过 15 行简化版(深拷贝 → 建 id 索引 → 插入/覆盖两分支),此处不再重复,只补两点。
第一点:完整版的四道校验——组校验(向非组行插入)、id 必填、目标缺失、name 不匹配——均为「警告并跳过该条」,不报错中止。原因:同一份 overlay 可能被多个界面共用,为 headless 写的补丁里有一行 web 树没有,不应导致整个 web 启动失败。函数首行深拷贝的原因(insert 的行按引用进树,防止覆盖被写入 bundle 层)上一篇第 8 站已讲,不再展开。
第二点:覆盖语义。两个 bundle 的 cordis.patch.yml 节选(插入与覆盖的形态上一篇已见过):
# dsh-base:一整摞 insert,78 行从这里进树
- insert:
- id: timer
name: '@deepseek-ai/cordis-plugin-timer'
# dsh-web-app:对 base 已有行的覆盖
- id: hmr
disabled: true
# 只覆盖 disabled,config 没带 → base 给的原样保留
补丁的每个键整体替换,不做字段级深合并——config 是一个键,携带即整份替换。只改 openAt 时必须重述全部保留字段:
# 想把 openAt 改成 first-search,这样写是错的:
- id: session-query-sqlite
config:
openAt: first-search
# path 字段没了:整份 config 被换成这一个字段
# 必须重述保留:
- id: session-query-sqlite
config:
path: ':memory:'
openAt: first-search
本机 web 的行数在本站确定:[] + 78 + 57 = 135 行。
本站输入 → 输出
输入 data = [](根文件)
+ 补丁摞(base 的 insert 78 条、web-app 的覆盖…)
输出 135 行的新列表,节选真实一行:
{ id: 'hmr',
name: '@deepseek-ai/cordis-plugin-hmr',
config: { root: ['.'] }, ← base 插入时给的
disabled: true } ← web-app 后来压的
本站完整链路
图:第 3 站完整链路——空清单叠四摞补丁合成 135 行
● ● ●
第 4 站:差异事务——树怎么接住一份新清单
合并出的 135 行交给树的 update(loader lib:76)。代码里没有独立的 diff 函数——差异由新旧两张 id 索引对比得出:
asyncupdate(config) {
// 先全体查重:同一层里 id 重复,当场报错
// 新列表逐行走 create——已存在的 id 就是「按新 options 更新」
constoutcomes=await Promise.allSettled(
config.map((options) => this.create(options)))
// 旧列表里有、新列表里没有的 id → 拆掉
// 全部成功 → 新列表正式接管
// 任何一步失败 → 回滚:新建的逆序拆掉、拆掉的重建回来
}
树不做全量重装:改一行补丁,134 行不变,仅 1 行走更新,这是热重载开销低的原因。任一步失败即整笔回滚,不停留在半更新状态。create 内部对单行的处理在下一站。
本站输入 → 输出
输入 新列表 135 行(第 3 站的输出),树的现状:
冷启动 = [](空树)
热重载 = 上一版 135 行
输出 冷启动:135 行全部 create
热重载:134 行差异为空不动,1 行进单行决策
本站完整链路
图:第 4 站完整链路——135 行清单进入差异事务
● ● ●
第 5 站:单行决策——不动、拆掉、原地重启、换插件重来
create 最终落到每一行的 entry.update(loader lib:395),决策分支如下:
路三(只改 config)是热重载最常走的分支:不换插件对象、不重导模块,把 fiber 拆除后按新 config 重新拉起,中间经过 internal/update 瀑布(上一篇讲过的否决点,第 6 站会被 include 使用)。路四(换插件)先拆旧行、再起新行,起新失败时回滚旧插件;错误信息携带行 id 与模块名,调用栈定位到配置行。
最后补上一篇未展开的一环:行的 config 如何进入插件、未写 config 的行使用什么默认值。三步(loader lib:527 → cordis lib:955 / 1066):
// 第一步:行启动,行里的 config 交给 registry.plugin
fiber=this.ctx.registry.plugin(plugin, this.options.config)
// 第二步:Fiber 启动前解析——插件声明了 Config schema 就过一遍校验
functionresolveConfig(runtime, config) {
if (!runtime.Config) returnconfig
// 非法 → 启动即抛 ValidationError
// 合法 → 返回标准化后的值,schema 里的默认值在这一步填充
returnruntime.Config["~standard"].validate(config).value
}
// 第三步:插件拿到 config——它是插件函数的第二个参数
// 类插件:new callback(ctx, this.config)
// 函数插件:callback(ctx, this.config)
示例是开篇那行 hmr(cordis-plugin-hmr lib:440):
Hmr.Config=z.object({
root:z.array(String).default(["."]),
debounce:z.natural().default(100),
})
base 给 hmr 写的 root: ['.'] 与 schema 默认值相同;debounce 没有任何一层提供,启动时由 schema 填充。行的 config 只是输入,插件实际收到的是 schema 解析后的值;未声明 Config 的插件原样传入。路三的重启走同一流程:新 config 重新过一遍 schema。这也解释了 dump 输出的一个现象:dump 打印的是盘上行,未写 config 的行为空,默认值在启动时才填充。
本站输入 → 输出
输入 → 输出(同一行的前后两份 options):
hmr: disabled: false → true
diff = [disabled] → 路二,拆掉
session-query-sqlite: openAt: never → first-search
diff = [config] → 路三,原地重启
本站完整链路
图:第 5 站完整链路——一行的四路决策与回滚
● ● ●
第 6 站:保存之后——一次热重载的完整内部链
前五站是冷启动路径。热重载的整条概览上一篇第 7 站已给出:保存 → hmr 监听 → 四层重组深拷贝 → entry.update 只改根行 config → include 重放补丁 → 树计算差异。本站只讲概览未覆盖的三个内部环节——否决、排队、基底不动。完整链路(app-boot lib:761 → include lib:139),增量标注在箭头处:
保存 cordis.patch.yml
↓ 上一篇第 7 站的概览部分:监听触发 → 四层重读、重叠、深拷贝
entry.update({ config: { patches } }) ← 对根行只改 config
↓ 决策表:diff 只有 config → 路三
fiber.update → internal/update 瀑布
↓ 增量一:Include 的监听者接住(path 匹配),
│ 不调 next → 否决默认的重启
↓ 增量二:enqueue 排进串行队列(第 2 站的那三行)
applyPatches(this.data, 新四摞) ← 增量三:基底还是根文件的 []
↓
root.update(合并结果) → 第 4 站差异事务收尾
这条链的关键:变更的是补丁,不是根文件。this.data(根文件解析结果)始终是 [],每次热重载都以「原基底 + 新四摞」重新合并,根文件在盘上不变。
修改生效靠新补丁参与合并,撤销靠删掉该行后合并结果复原,两个方向都不触碰根文件。
冷启动的组装线被热重载完整复用——同一条线,冷时叫启动,热时叫重载。
本站输入 → 输出
输入 cordis.patch.yml 新增的一行(第 3 站的正确写法):
- id: session-query-sqlite
config: { path: ':memory:', openAt: first-search }
输出 树仍为 135 行:该行 config 变化 → 路三原地重启;
其余 134 行 diff 为空,不动
本站完整链路
图:第 6 站完整链路——一次补丁热重载的完整内部链
● ● ●
第 7 站:透视镜——dump-config 为什么可信
回到开篇的命令。dsh web --dump-config 的输出与真实启动一致,原因在合并入口:dump 干跑走的 composeEntries 调用的正是第 3 站的 applyEntryPatches——同一函数、同一输入,boot 时建树,dump 时打印。源码注释:a dump can never drift from what boots(dump 永不偏离启动)。
dump 输出中,每个区段顶部的 # == 注释标明出处(真实输出):
# == @deepseek-ai/dsh-base
- id: timer
name: '@deepseek-ai/cordis-plugin-timer'
# == @deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-web-app
- id: hmr
disabled: true
hmr 行即第 3 站示例的验证:行来自 base,disabled 由 web-app 覆盖。据此,「插件为什么没生效」的排查对应三个检查项,全部可在 dump 中直接完成:是否进树(检索行 id)、是否被覆盖(看 # == 注释的 patched by 与 config 是否被整份重述)、是否被禁用(看 disabled 或 !!js 在当前平台的求值结果)。
本站输入 → 输出
输入 $ dsh web --dump-config
输出 135 行清单 + 每行顶部的 # == 出处注释
(即上面那段真实输出,与启动一致)
本站完整链路
图:第 7 站完整链路——dump 与 boot 共用一条合并线
● ● ●
对照:每个用户动作对应的内部路径
dsh web(目录不存在) | [](第 1 站) |
dsh web --patch ./试验.yml | |
dsh web --dump-config | # == 标注每行出处(第 7 站) |
收尾重复口诀:补丁四层摞,后写盖前写;行是数据,树认 id;冷热一条线,dump 是干跑。这份 135 行的清单始终只是数据:从四个文件叠出,热重载时重新合并,dump 时干跑合并;树只对行的集合做差异事务,与行所属的层无关。下一篇拆这棵树上最核心的一行——Agent Loop 的 kick、turn、step 双层循环。
素材(行号=npm 编译产物 lib/index.js):profile-boot lib:147/166 · app-boot lib:540/575/761/793/812/964 · include lib:57/116/129/157/170/203/233/243 · loader lib:76/119/373/395/454/483/527 · cordis lib:955/1066/1409/1426 · cordis-plugin-hmr lib:440 · dsh-base、dsh-web-app 的 cordis.patch.yml · 本机实跑 dump(/tmp/dsh-dump-web.txt)
夜雨聆风