Main Chain A · Step 02

loadProfile:profile 发现与补丁层组合

packages/boot/app-boot/src/profile.ts(724 行)是「配置树」的物质来源:找到 profile 目录、解析它的 dsh.profile.bundles、把每个 bundle 的补丁文件读成补丁列表,再加上 profile 自己的用户层。五层补丁中的前两层在这里就位。

bin.ts:24 runProfile → profile-boot.ts:166 prepareProfile → profile.ts:690 loadProfile → (下一步) profile.ts:717 composeEntries → 03 boot

示例本次示例:dsh web 在你机器上的 profile

示例轨迹 02-1 · 真实 profile 目录的内容(本机 $DSH_HOME = C:\Users\充电宝\.dsh)
$ 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 行)
示例轨迹 02-2 · 本次 loadProfile 的返回值形状
# 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 可声明多个补丁文件)
来源:02 页 Profile 类型(profile.ts:78-101)+ 本机真实 profile

下一站:03 boot()——五层补丁里的前两层已经变成 PatchOptions[]。

§1先看数据形状:Profile 类型

packages/boot/app-boot/src/profile.ts核心类型78-101, 158-174
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}
78-87

一个 bundle 层 = 包名 + 目录 + 补丁路径 + 已解析的补丁列表。注意补丁在 loadProfile 里就已经解析好了(不是懒加载)——组装阶段是纯数据操作。0.1.7-rc.1:patchPath 改名为 patchPaths(readonly string[])——一个 bundle 现在可以声明多个补丁文件(bundlePatchFiles 接受字符串或字符串数组,见 profile.ts:58),解析结果按声明顺序拼接。profile 自己的 patchPath(98 行)仍是单数。

90-101

Profile = bundle 层们 + 用户层。用户层(patches)与 bundle 层分离存放——因为两者在热更新里的地位不同(04 页:用户层可热重载,bundle 层是基底)。

100-101

0.1.6-alpha.2:patchReload 字段已被删除(所以 101 行直接闭合,比旧版少两行)。旧版每个模板要声明 'live'(改配置立即重组树)或 'startup'(只读一次);现在热重载由 packages/boot/hmr 服务统一负责——它读 profileContext 就知道该观察哪些文件,不需要 profile 声明策略。这也解释了 04 页为什么从 413 行缩到 337 行:判定逻辑随字段一起消失。

158-174

出厂模板:acp/web/headless/sdk/sdk-minimal(0.1.6 新增后者)各有自己的 bundle 组合。四个模板共享同一个 base(只有 sdk-minimal 是例外,它只挂 @deepseek-ai/dsh-sdk-minimal 一个包)——这就是「模式差异」的实现:差异只在于多挂了哪个 app bundle。

§2loadProfile:主体

packages/boot/app-boot/src/profile.tsloadProfile 全文 + loadProfileDirectory 主体690-707, 642-675
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}
694

profile 目录 = $DSH_HOME/profiles/<name>。注意 resolveProfileDir(148-155 行)里有名字合法性校验:空、含路径分隔符、./..、node_modules 都被拒——防止路径逃逸。

695-703

首次使用自动初始化:profile 目录不存在时,若名字有出厂模板(acp/web/headless/sdk)就自动 initProfile(219-238 行:写 package.json + 空 cordis.patch.yml + pnpm-workspace.yaml);没模板就报错并提示用 dsh plugin 创建。这就是「dsh web 第一次跑就能用」的原因。

704

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 做的迁移清理。

706

0.1.3-alpha.2 起 loadProfile 被拆成两半:loadProfile(690-707)负责「按 home 里的名字找到目录、必要时用模板初始化」,然后转交 loadProfileDirectory(642-675)做真正的读取与解析。拆分的理由写在 630-641 行的 JSDoc:应用的自有 profile(package 工程与生命周期属于那个应用)不该被强制经由共享的 Harness home 解析——它们直接调 loadProfileDirectory,传入自己的目录与锚点。

705

normalizeShippedProfile(566-585 行):如果 bundle 列表恰好等于某个「安装方拥有」的历史元组,就规范化到当前出厂模板并写回。这是发布期兼容——老 profile 的 bundle 组合升级到新模板。

649

手写的 profile manifest 可以完全没有 dsh 段——此时 bundles 为空数组,等价于一个空 profile。

648-651

0.1.6-alpha.2:patchReload 的校验整段消失。旧版这里要读 rawPatchReload、校验它只能是 'live' 或 'startup'、再取默认值——现在只剩「读 manifest,拿 bundles」。校验消失是因为字段消失:没有可声明的策略,就没有可校验的值。0.1.7-rc.1 又多了一行 exemptions:bundle 版本豁免表,在装补丁前先读。

652-669

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 行)会把它们算进运行时解析的诊断里。

670-673

用户层:profile 自己的 cordis.patch.yml。注意 options.userLayer: false 时跳过——这是 --dump-default-config 走的路径(01 页的 defaultOnly),保证 dump 不因坏掉的用户层而失败。

§3resolveBundleDir:双锚点解析

packages/boot/app-boot/src/profile.ts安装优先的解析契约617-628
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}
605-610

两个锚点、安装优先:先按 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 也改变不了它从安装处来。

595-603

packageDirFromAnchor(内部辅助):不用 require.resolve(那需要包导出 package.json),而是拿 createRequire(anchor).resolve.paths() 的搜索路径逐个探测——与 Node 自己的 node_modules 查找顺序一致,保证「Loader 实际会 import 什么,解析到的就是什么」。

624-627

两个锚点都找不到 → 报错并给修复指引(用 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:补丁叠加

packages/boot/app-boot/src/profile.ts组合成条目表717-724
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}
717-719

输入是「补丁列表的列表」——每一层是一个 PatchOptions[],按应用顺序传入。输出是最终的条目表(id → Entry)。

720

关键:applyEntryPatches 来自 vendored 的 @deepseek-ai/cordis-plugin-include(vendor/include/src/index.ts)——补丁算法的唯一定义点。它应用在空列表上(第一个参数 [])。

720

为什么 structuredClone?(这是全库最重要的 gotcha 之一,04 页还会遇到):insert 行按引用 push 进挂载树,后续 id 定向补丁原地 mutate这些对象。不 clone 的话,用户 override 会「烤」进 bundle 的内存 insert 行——之后删除 override 也无法还原。所以每次组合前都 clone。

721-723

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() 里会再次出现。