Main Chain A · Step 02
loadProfile:profile 发现与补丁层组合
packages/boot/app-boot/src/profile.ts(724 行)是「配置树」的物质来源:找到 profile 目录、解析它的 dsh.profile.bundles、把每个 bundle 的补丁文件读成补丁列表,再加上 profile 自己的用户层。五层补丁中的前两层在这里就位。
示例本次示例:dsh web 在你机器上的 profile
$ ls ~/.dsh
profiles/ sessions/ settings.yaml storages/
$ ls ~/.dsh/profiles
node_modules/ web/
$ cat ~/.dsh/profiles/web/package.json
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app"
]
}
}
}
dsh.profile.bundles 就是 02 页 loadProfileDirectory:649 读的那个字段;web 目录是首次运行 dsh web 时 initProfile(profile.ts:219)自动创建的(PROFILE_TEMPLATES.web,162-164 行)# loadProfile('dsh', 'web', INSTALL_ANCHOR) 返回(02 页 690-707 → 642-675 行):
{
name: 'web',
dir: 'C:/Users/充电宝/.dsh/profiles/web',
layers: [
{ packageName: '@deepseek-ai/dsh-base',
packageDir: 'D:/dev/sourcecode/deepseek-harness/packages/bundle/base', ← 安装优先!
patchPaths: ['.../packages/bundle/base/cordis.patch.yml'],
patches: [78 个 insert/disable 补丁] },
{ packageName: '@deepseek-ai/dsh-web-app',
packageDir: '.../packages/bundle/web-app',
patchPaths: ['.../web-app/cordis.patch.yml'],
patches: [UI 条目补丁] },
],
patchPath: 'C:/Users/充电宝/.dsh/profiles/web/cordis.patch.yml',
patches: [] ← 用户层是空数组(本机还没写过任何定制)
} // 0.1.7-rc.1:层的 patchPath 改为 patchPaths(bundle 可声明多个补丁文件)
下一站:03 boot()——五层补丁里的前两层已经变成 PatchOptions[]。
§1先看数据形状:Profile 类型
78export interface ProfileLayer {
80 packageName: string // dsh.profile.bundles 里列的名字
82 packageDir: string // bundle 包的绝对目录
84 patchPaths: readonly string[] // 0.1.7-rc.1:由 patchPath 改名为 patchPathsbundle 可声明多个补丁文件
86 patches: PatchOptions[] // 解析后的补丁列表(多个文件按顺序拼接)
87}
90export interface Profile {
92 name: string
94 dir: string
96 layers: ProfileLayer[] // bundle 层,按 dsh.profile.bundles 顺序
98 patchPath: string // profile 自己的 cordis.patch.yml 路径(仍是单数)
100 patches: PatchOptions[] // 用户层补丁;文件不存在时为空0.1.6-alpha.2:patchReload 字段已删(101 行直接闭合)
101}
158export const PROFILE_TEMPLATES: Record<string, ProfileTemplate> = {
160 bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
163 bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
166 bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
169 bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
172 bundles: ['@deepseek-ai/dsh-sdk-minimal'], // 0.1.6-alpha.1 新增最小 SDK 模板(不带 base)
174}
一个 bundle 层 = 包名 + 目录 + 补丁路径 + 已解析的补丁列表。注意补丁在 loadProfile 里就已经解析好了(不是懒加载)——组装阶段是纯数据操作。0.1.7-rc.1:patchPath 改名为 patchPaths(readonly string[])——一个 bundle 现在可以声明多个补丁文件(bundlePatchFiles 接受字符串或字符串数组,见 profile.ts:58),解析结果按声明顺序拼接。profile 自己的 patchPath(98 行)仍是单数。
Profile = bundle 层们 + 用户层。用户层(patches)与 bundle 层分离存放——因为两者在热更新里的地位不同(04 页:用户层可热重载,bundle 层是基底)。
0.1.6-alpha.2:patchReload 字段已被删除(所以 101 行直接闭合,比旧版少两行)。旧版每个模板要声明 'live'(改配置立即重组树)或 'startup'(只读一次);现在热重载由 packages/boot/hmr 服务统一负责——它读 profileContext 就知道该观察哪些文件,不需要 profile 声明策略。这也解释了 04 页为什么从 413 行缩到 337 行:判定逻辑随字段一起消失。
出厂模板:acp/web/headless/sdk/sdk-minimal(0.1.6 新增后者)各有自己的 bundle 组合。四个模板共享同一个 base(只有 sdk-minimal 是例外,它只挂 @deepseek-ai/dsh-sdk-minimal 一个包)——这就是「模式差异」的实现:差异只在于多挂了哪个 app bundle。
§2loadProfile:主体
690export function loadProfile(
691 binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),
692 options: { userLayer?: boolean } = {},
693): Profile {
694 const dir = resolveProfileDir(name, home)
695 if (!existsSync(join(dir, 'package.json'))) {
696 const template = PROFILE_TEMPLATES[name]
697 if (template === undefined) {
698 throw new Error(
699 `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
700 )
701 }
702 initProfile(dir, template.bundles)
703 }
704 removeLinkProjections(dir) // 0.1.7-rc.1 新增:清理旧版 link 后端留下的投影取代被删除的 heal 系列
705 normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
706 return loadProfileDirectory(binName, dir, installAnchor, options)
707}
642export function loadProfileDirectory(binName: string, dir: string, installAnchor: string,
646 options: { userLayer?: boolean } = {}): Profile {
648 const manifest = readProfileManifest(binName, dir)
649 const bundles = manifest.dsh?.profile?.bundles ?? []
650 const layers: ProfileLayer[] = []
651 const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
652 for (const packageName of bundles) {
653 try {
654 const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
655 const bundleManifest = readProfileManifest(binName, packageDir)
656 const bundle = bundleManifest.dsh?.bundle
657 if (bundle === undefined) {
658 throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
659 }
661 const issue = evaluatePluginCompatibility(bundleManifest, exemptions)
662 if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue))
663 const patchPaths = bundlePatchPaths(packageDir, bundle) // 0.1.7-rc.1:0..N 个补丁文件取代旧版的 join(packageDir, declared)
664 const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
665 layers.push({ packageName, packageDir, patchPaths, patches })
666 } catch (error) {
667 process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${String(error)}\n`) // 不再 fail-loud坏 bundle 跳过而非中断启动
668 }
669 }
670 const patchPath = join(dir, PROFILE_PATCH_FILENAME)
671 const patches = options.userLayer !== false && existsSync(patchPath)
672 ? loadOverlayPatches(binName, patchPath)
673 : []
674 return { name: basename(dir), dir, layers, patchPath, patches } // 无 patchReload0.1.6-alpha.2
675}
profile 目录 = $DSH_HOME/profiles/<name>。注意 resolveProfileDir(148-155 行)里有名字合法性校验:空、含路径分隔符、./..、node_modules 都被拒——防止路径逃逸。
首次使用自动初始化:profile 目录不存在时,若名字有出厂模板(acp/web/headless/sdk)就自动 initProfile(219-238 行:写 package.json + 空 cordis.patch.yml + pnpm-workspace.yaml);没模板就报错并提示用 dsh plugin 创建。这就是「dsh web 第一次跑就能用」的原因。
0.1.7-rc.1 新增 removeLinkProjections(dir)(250-258 行):把旧版 link 后端在 profile 里留下的包投影清掉——只 unlink 指向 <profile>/.dsh-module-fallback/node_modules 的符号链接,再删掉那个目录;pnpm 装的包和别的链接一律不动。这是给从 0.1.5/0.1.6 升上来的 profile 做的迁移清理。
0.1.3-alpha.2 起 loadProfile 被拆成两半:loadProfile(690-707)负责「按 home 里的名字找到目录、必要时用模板初始化」,然后转交 loadProfileDirectory(642-675)做真正的读取与解析。拆分的理由写在 630-641 行的 JSDoc:应用的自有 profile(package 工程与生命周期属于那个应用)不该被强制经由共享的 Harness home 解析——它们直接调 loadProfileDirectory,传入自己的目录与锚点。
normalizeShippedProfile(566-585 行):如果 bundle 列表恰好等于某个「安装方拥有」的历史元组,就规范化到当前出厂模板并写回。这是发布期兼容——老 profile 的 bundle 组合升级到新模板。
手写的 profile manifest 可以完全没有 dsh 段——此时 bundles 为空数组,等价于一个空 profile。
0.1.6-alpha.2:patchReload 的校验整段消失。旧版这里要读 rawPatchReload、校验它只能是 'live' 或 'startup'、再取默认值——现在只剩「读 manifest,拿 bundles」。校验消失是因为字段消失:没有可声明的策略,就没有可校验的值。0.1.7-rc.1 又多了一行 exemptions:bundle 版本豁免表,在装补丁前先读。
bundle 层解析循环:每个名字 → 解析目录 → 读它的 package.json → 取 dsh.bundle 声明 → 解析补丁文件(bundlePatchPaths,0..N 个)→ flatMap 成一份补丁列表。657-659 仍对「没有 dsh.bundle 声明」报错,但整个循环体被 try/catch 包住了:0.1.7-rc.1 起坏的 bundle 只往 stderr 写一行 skipping profile bundle … 然后继续,不再中断启动——skippedProfileBundles(458 行)会把它们算进运行时解析的诊断里。
用户层:profile 自己的 cordis.patch.yml。注意 options.userLayer: false 时跳过——这是 --dump-default-config 走的路径(01 页的 defaultOnly),保证 dump 不因坏掉的用户层而失败。
§3resolveBundleDir:双锚点解析
617export function resolveBundleDir(
618 binName: string, packageName: string, installAnchor: string, profileDir: string,
619): string {
620 for (const anchor of [installAnchor, join(profileDir, 'package.json')]) {
621 const dir = packageDirFromAnchor(anchor, packageName)
622 if (dir !== undefined) return dir
623 }
624 throw new Error(
625 `${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; `
626 + `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`,
627 )
628}
两个锚点、安装优先:先按 dsh 安装位置(launcher 自己的 package.json)解析,再按 profile 目录解析。这是契约——JSDoc 原文:「installation-first order is the contract that @deepseek-ai/dsh-base always comes from the same installation as the running dsh, never from a profile-local copy」。你 clone 一份 bundle 放进 profile 也改变不了它从安装处来。
packageDirFromAnchor(内部辅助):不用 require.resolve(那需要包导出 package.json),而是拿 createRequire(anchor).resolve.paths() 的搜索路径逐个探测——与 Node 自己的 node_modules 查找顺序一致,保证「Loader 实际会 import 什么,解析到的就是什么」。
两个锚点都找不到 → 报错并给修复指引(用 dsh plugin 装)。fail-loud,不静默跳过。
配套的还有一件在 0.1.7-rc.1 被整体换掉的事:旧版的 healProfilesModuleFallback / healIsolatedProfileModuleFallback(在 $DSH_HOME/profiles/node_modules 里为依赖闭包的每个包建 symlink)已经删除,连 createProfileResolutionGeneration 那套三态('link' | 'dual' | 'runtime')的 ProfileResolutionMode / ProfileResolutionEntry 也一并消失。取而代之的是 createRuntimeResolution(options)(profile.ts:406-439):它不写任何模块解析文件,只算出一张不可变的包表 RuntimeResolution(129-140 行:profilesDir / profileDir / localPackageNames / entries / linkedRoots),安装域条目在前、profile 域条目在后。这张表交给 profile-resolution/ 下的运行时拦截服务使用,由它在 Node 的解析层直接把包名映射到目录——所以 out-of-tree 插件共享安装里的同一个 cordis 实例这件事依然成立,只是不再靠文件系统里的 symlink 森林,而是靠进程内的解析表。附带产物是 LinkedRoot(121-126 行):指向 profiles 树之外的链接根,交给运行时逐级读取 peer 映射。
§4composeEntries:补丁叠加
717export function composeEntries(
718 layers: readonly PatchOptions[][], warn: (message: string) => void = () => {},
719): EntryOptions[] {
720 return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => {
721 let index = 0
722 warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
723 })
724}
输入是「补丁列表的列表」——每一层是一个 PatchOptions[],按应用顺序传入。输出是最终的条目表(id → Entry)。
关键:applyEntryPatches 来自 vendored 的 @deepseek-ai/cordis-plugin-include(vendor/include/src/index.ts)——补丁算法的唯一定义点。它应用在空列表上(第一个参数 [])。
为什么 structuredClone?(这是全库最重要的 gotcha 之一,04 页还会遇到):insert 行按引用 push 进挂载树,后续 id 定向补丁原地 mutate这些对象。不 clone 的话,用户 override 会「烤」进 bundle 的内存 insert 行——之后删除 override 也无法还原。所以每次组合前都 clone。
warn 回调:把 include 库的 %C 占位符格式转成 JSON 字符串。用于诊断「被跳过的补丁」(比如 patch 了一个不存在的 id)。
§5五层补丁清单
到这里,五层补丁的前两层(bundle 层、profile 用户层)已经在 loadProfile 里变成 PatchOptions[]。完整的五层在 04 页的 composeProfile 里拼齐:
| 层 | 来源 | 在本页还是 04 页就位 |
|---|---|---|
| ① bundle 层 | 每个 bundle 的 dsh.bundle.patch 文件(可多个),按 dsh.profile.bundles 顺序 | 本页 loadProfileDirectory:652-669 |
| ② profile 用户层 | $DSH_HOME/profiles/<name>/cordis.patch.yml | 本页 loadProfileDirectory:670-673 |
| ③ home 用户层 | $DSH_HOME/cordis.patch.yml(机器级偏好,高于 profile 层) | 04 页 composeProfile |
| ④ --patch overlay | 命令行 overlay,按 argv 顺序 | 04 页 composeProfile(args.ts:169 收集) |
| ⑤ 遥测开关 | DSH_TELEMETRY_DISABLED 非空即禁用(含 '0'/'false'——隐私开关宁可误关) | 04 页 resolveTelemetryPatch(profile-boot.ts:166-167 调用链) |
patch 的替换语义是「整行替换」,不是深合并。一个 patch 命中某 id 时替换该行的整个 config。因此与模式相关的行不放在 base bundle,而归每个模式 bundle 各自重述完整配置。
cordis.patch.yml 不是 Loader 直接挂载的配置——它只是一份数据。真正的 root 配置是 profile-boot.ts 每次重写的空 cordis.yml(PROFILE_ROOT_CONFIG,profile-boot.ts:80-84;只为给 Loader 一个 include 根锚定 baseUrl)。这个「数据 vs 挂载」的区分在 03 页 boot() 里会再次出现。