docs(boot): call the package table the runtime resolution

Notes and the app-boot README name the startup package table the runtime resolution and the installed hook the interception; generation remains the word for the additive successor semantics of replace().
This commit is contained in:
imccyu committed 2026-09-19 17:06:34 +08:00
1 parent bffd317c8c
commit 5f48b2fff6
12 files changed
+70 -70

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
2026-09-09-profile-resolution-generations.md: 2ed353b0f43acff069804df7c080f50406750f84
2026-09-09-profile-resolution-generations.zh.md: 0bf53f16dbfeebe7c2ebc05d4e77bf1111e2e98f
2026-09-09-profile-resolution-generations.md: 96e77a294f2c8032e221e8012ff531ed904465c6
2026-09-09-profile-resolution-generations.zh.md: eedc0802648e21244edb1fe71d3bea7fbcba2ea9
@@ -12,7 +12,7 @@ The runtime design keeps installation-first, ordered-bundle, and local-before-fa
## Decision
Profile startup computes one immutable `ProfileResolutionGeneration` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
Profile startup computes one immutable `RuntimeResolution` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
### One selection algorithm
@@ -104,7 +104,7 @@ Replacing, upgrading, or removing an already loaded package requires process res
### Filesystem and runtime carriers
The resolver does not create, update, or remove fallback symlinks and proxy packages. Generation entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor, and symlinks anywhere in the profile tree are ordinary filesystem content without dedicated recognition. Profile-local package metadata and bundle dependency discovery follow the same Node lookup; ordinary pnpm-installed packages retain native precedence. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records the complete order. Writable profile state and package-manager transactions remain outside the resolver.
The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor, and symlinks anywhere in the profile tree are ordinary filesystem content without dedicated recognition. Profile-local package metadata and bundle dependency discovery follow the same Node lookup; ordinary pnpm-installed packages retain native precedence. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records the complete order. Writable profile state and package-manager transactions remain outside the resolver.
Runtime resolution requires a supported Node Internal loader interface. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Pkg and Electron use the same runtime-generation mechanism as ordinary Node launches.
@@ -12,7 +12,7 @@ profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bu
## Decision
profile 启动生成一个不可变 `ProfileResolutionGeneration`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
profile 启动生成一个不可变 `RuntimeResolution`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
### 唯一选包算法
@@ -104,7 +104,7 @@ resolution generation 列出可用 fallback 包;Loader entries 组成活动插
### 文件系统与运行时载体
解析器不创建、更新或删除 fallback 软链接与代理包。generation 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先,profile 树内任何位置的软链接都是普通文件系统内容,没有专门识别。profile 本地包元数据与 bundle 依赖展开遵循同一套 Node 查找;正常由 pnpm 安装的包保留原生优先级。完整顺序见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。
解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先,profile 树内任何位置的软链接都是普通文件系统内容,没有专门识别。profile 本地包元数据与 bundle 依赖展开遵循同一套 Node 查找;正常由 pnpm 安装的包保留原生优先级。完整顺序见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。
运行时解析要求受支持的 Node Internal loader 接口。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。pkg 与 Electron 使用和普通 Node 启动相同的 runtime generation 机制。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
2026-09-10-desktop-web-wrapper.md: fb2d3c88b756f2b5f59083f2501586e6f05b6476
2026-09-10-desktop-web-wrapper.zh.md: cf1947a736ad54232edaa73075308f7dcfccb11a
2026-09-10-desktop-web-wrapper.md: 4f43cc650777596c807cf79463edc7a9131db44b
2026-09-10-desktop-web-wrapper.zh.md: bc41b4f8eff1dca7fc3dd9b934dac257a9d10a6b
@@ -14,7 +14,7 @@ Separate Desktop composition and request transport require their own configurati
The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, the runtime resolution generation, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates.
The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, the runtime resolution, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates.
The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The public CLI continues to reject the reserved Desktop profile. Electron profile preparation and native recovery remain available without a working Host.
@@ -22,7 +22,7 @@ Independent package ownership prevents CLI and Desktop from modifying each other
The shared Web plugin manager owns installation, activation, errors, and restart requirements. Electron has no separate plugin-management renderer, preload, shell asset route, plugin IPC, or package-mutation runner. Packaging explicitly selects the main entry and application preload so stale build outputs cannot restore the removed bridge.
Shared `initProfile` creates missing profile files and preserves existing content. The Host computes its installation and bundle generation with `createProfileResolutionGeneration` and installs it through `PluginPackages`, without writing fallback links. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism.
Shared `initProfile` creates missing profile files and preserves existing content. The Host computes its runtime resolution with `createRuntimeResolution` and installs it through `PluginPackages`, without writing fallback links. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism.
This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
@@ -36,7 +36,7 @@ Native directory selection in the local application uses a narrow preload IPC ca
**Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.
**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single owner of the runtime resolution generation can protect pnpm directories without maintaining release identity in the plugin profile.
**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single owner of the runtime resolution can protect pnpm directories without maintaining release identity in the plugin profile.
**Keep a separate native plugin-management page.** It can operate while the Web Host is unavailable, but duplicates package operations, renderer IPC, localization, and backend restart handling. Native recovery already supports disabling third-party bundles and backing up the profile patch without the Host. Reintroduction requires a repair operation that this recovery cannot provide.
@@ -14,7 +14,7 @@ Status: implemented
私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、运行时解析 generation、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。
共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、runtime resolution、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。
[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。公开 CLI 继续拒绝保留的 Desktop profile。Electron profile 准备和原生恢复在 Host 不可用时仍可执行。
@@ -22,7 +22,7 @@ Status: implemented
共享 Web 插件管理器负责安装、激活、错误和重启要求。Electron 不提供独立插件管理渲染器、preload、shell 资源路由、插件 IPC 或包变更执行器。打包明确选择主入口和应用 preload,避免旧构建产物重新带入已删除的桥接。
共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 通过 `createProfileResolutionGeneration` 计算安装包与 bundle 的 generation,并通过 `PluginPackages` 安装它,不写入 fallback 链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。
共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 通过 `createRuntimeResolution` 计算 runtime resolution,并通过 `PluginPackages` 安装它,不写入 fallback 链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。
本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
@@ -36,7 +36,7 @@ Status: implemented
**合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。
**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一运行时解析 generation 归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一 runtime resolution 归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
**保留独立原生插件管理页。** 它可以在 Web Host 不可用时运行,但重复维护包操作、渲染器 IPC、本地化和后端重启处理。原生恢复已能在没有 Host 时禁用第三方 bundle 并备份 profile patch。只有出现此恢复方式无法提供的修复操作时,才应重新引入。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md
2026-09-19-profile-resolution-lookup-order.md: d17fe448a3962c8a180bbe69c2edeb1ac1f247f1
2026-09-19-profile-resolution-lookup-order.zh.md: 6f858014a9e8ee40fec8134fce047bfbf777b2c6
2026-09-19-profile-resolution-lookup-order.md: 604cd7901289cf43f3a4ccedafa853bdd9e960ff
2026-09-19-profile-resolution-lookup-order.zh.md: 40cc5c81a42624f58c113f2c266c5ec3bd95214a
@@ -12,7 +12,7 @@ A developer must be able to restate the resolution rule in one sentence: which p
## Decision
Module resolution inside a profile is an ordinary Node ancestor `node_modules` chain. dsh does exactly one thing: every package name in the generation computed at startup occupies the package directory position `$DSH_HOME/profiles/node_modules/<package>`. When the chain reaches this layer, a package name with an entry is answered by the generation's package, and a package name without an entry sees the physical directory at this layer. Every other resolution behavior matches Node. [Construction, publication, Worker inheritance, and runtime carriers of immutable generations](2026-09-09-profile-resolution-generations.md) are recorded by the existing Note and are not repeated here.
Module resolution inside a profile is an ordinary Node ancestor `node_modules` chain. dsh does exactly one thing: every package name in the runtime resolution computed at startup occupies the package directory position `$DSH_HOME/profiles/node_modules/<package>`. When the chain reaches this layer, a package name with an entry is answered by the runtime resolution's package, and a package name without an entry sees the physical directory at this layer. Every other resolution behavior matches Node. [Construction, publication, Worker inheritance, and runtime carriers of immutable generations](2026-09-09-profile-resolution-generations.md) are recorded by the existing Note and are not repeated here.
### Part 1: Resolution rules
@@ -27,26 +27,26 @@ The chain below defines the numbering that every table in this Note references.
```text
① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules 插件私有依赖
② $DSH_HOME/profiles/web/node_modules profile 安装的包
③ $DSH_HOME/profiles/node_modules 拦截层:generation 占据其中有条目的包目录
③ $DSH_HOME/profiles/node_modules 拦截层:runtime resolution 占据其中有条目的包目录
④ $DSH_HOME/node_modules
⑤ /node_modules,随后是 NODE_PATH 与全局目录
```
#### 2. The interception layer: the generation occupies `$DSH_HOME/profiles/node_modules/<package>`
#### 2. The interception layer: the runtime resolution occupies `$DSH_HOME/profiles/node_modules/<package>`
The interception layer is the first ancestor `node_modules` above the importer's profile directory (`$DSH_HOME/profiles/<name>`). For every profile inside the tree this layer is `$DSH_HOME/profiles/node_modules`.
Every package name in the generation occupies the package directory position `<interception layer>/<package>` at this layer. For an installation package name such as `@deepseek-ai/dsh-tools`, the package directory at ③ is the copy of the running installation; an old link or old directory of the same name on disk is no longer a member of the chain. For a package name absent from the generation, the package directory at ③ is the physical content on disk.
Every package name in the runtime resolution occupies the package directory position `<interception layer>/<package>` at this layer. For an installation package name such as `@deepseek-ai/dsh-tools`, the package directory at ③ is the copy of the running installation; an old link or old directory of the same name on disk is no longer a member of the chain. For a package name absent from the runtime resolution, the package directory at ③ is the physical content on disk.
The order for one bare package name request: the ancestor chain first walks the layers inside the profile directory (① and ②), and when a candidate exists Node resolves inside that candidate and returns. On reaching ③, if the generation has an entry for that package name, Node resolves inside the package directory the entry records; a hit returns, and a missing file inside the package continues per Node's own handling of a "found package": CommonJS keeps looking for the subpath layer by layer from ④, ESM fails immediately. If the generation has no entry, Node looks at the physical directory at ③ and then moves on to ④.
The order for one bare package name request: the ancestor chain first walks the layers inside the profile directory (① and ②), and when a candidate exists Node resolves inside that candidate and returns. On reaching ③, if the runtime resolution has an entry for that package name, Node resolves inside the package directory the entry records; a hit returns, and a missing file inside the package continues per Node's own handling of a "found package": CommonJS keeps looking for the subpath layer by layer from ④, ESM fails immediately. If the runtime resolution has no entry, Node looks at the physical directory at ③ and then moves on to ④.
Node remains responsible for `exports`, `imports`, conditions, `main`, subpaths, extensions, caching, and error codes. When the selected package rejects a subpath through `exports`, the error is final; Node does not switch to another package of the same name.
The interception layer reads, writes, and deletes no disk links. Leftover historical symlinks are treated as ordinary filesystem content: the positions of installation package names at ③ are already occupied by the generation, so old links are never read; all other package names see the contents of ③ normally along the ancestor chain. Profile load removes, once, the projections the link backend of the dsh 0.1.5 releases wrote into a profile: symlinks under the profile's `node_modules` whose target lies inside `<profile>/.dsh-module-fallback/node_modules`, followed by that directory. pnpm-installed packages and every other symlink stay.
The interception layer reads, writes, and deletes no disk links. Leftover historical symlinks are treated as ordinary filesystem content: the positions of installation package names at ③ are already occupied by the runtime resolution, so old links are never read; all other package names see the contents of ③ normally along the ancestor chain. Profile load removes, once, the projections the link backend of the dsh 0.1.5 releases wrote into a profile: symlinks under the profile's `node_modules` whose target lies inside `<profile>/.dsh-module-fallback/node_modules`, followed by that directory. pnpm-installed packages and every other symlink stay.
#### 3. What the interception layer holds: the generation's scan contents
#### 3. What the interception layer holds: the runtime resolution's scan contents
The generation is computed once at profile startup and consists of three parts.
The runtime resolution is computed once at profile startup and consists of three parts.
- Installation closure: starting from the `package.json` of the currently running dsh package, a breadth-first traversal follows `dependencies` and `peerDependencies`; each edge resolves from the manifest that declares it per Node rules, and the first installed package found owns a package name. The current source tree measures 539 entries, of which 258 are in the `@deepseek-ai/` scope and 281 are third-party libraries. These entries apply to every profile.
- Bundle-only entries: for a bundle selected by the profile that is not part of the closure, the same traversal starts from its manifest, and package names the closure already owns are not overridden. These entries apply only to profiles that select that bundle; they let the Loader import the bundle's embedded plugins by bare name from the profile root.
@@ -58,26 +58,26 @@ During the traversal, every dependency level uses the real directory of its owni
The hook participates only when the importer is under `$DSH_HOME/profiles/**`. Builtin, relative-path, absolute-path, and URL requests, and every request whose importer is outside the tree, go straight to Node.
The ESM and CommonJS adapters call the same routing function, and the main thread and Harness-owned Workers install the same generation. The implementation lives in `packages/boot/app-boot/src/profile-resolution/resolver.ts`; generation construction lives in `packages/boot/app-boot/src/profile.ts`.
The ESM and CommonJS adapters call the same routing function, and the main thread and Harness-owned Workers install the same runtime resolution. The implementation lives in `packages/boot/app-boot/src/profile-resolution/resolver.ts`; runtime resolution construction lives in `packages/boot/app-boot/src/profile.ts`.
### Part 2: Walkthroughs
#### Table 1: Ancestor chain and interception for ESM and CommonJS across request forms
The table uses the installation package `@deepseek-ai/dsh-tools` (has a generation entry) and the third-party library `left-pad` (no generation entry) as examples; `pkg` stands for either.
The table uses the installation package `@deepseek-ai/dsh-tools` (has a runtime resolution entry) and the third-party library `left-pad` (no runtime resolution entry) as examples; `pkg` stands for either.
| Request form | ESM | CommonJS | Position of the package name at ③ | When a file is missing inside the package |
|---|---|---|---|---|
| Bare name `pkg`, package has `exports` | If ① or ② has a `pkg` directory, Node takes the entry point from its `exports` | Same as ESM; a candidate directory at ① or ② whose entry file is missing does not count as a candidate | With entry: the generation's package; without entry: the physical directory | A missing entry point under `exports` is an `exports` error, final |
| Bare name `pkg`, package has `exports` | If ① or ② has a `pkg` directory, Node takes the entry point from its `exports` | Same as ESM; a candidate directory at ① or ② whose entry file is missing does not count as a candidate | With entry: the runtime resolution's package; without entry: the physical directory | A missing entry point under `exports` is an `exports` error, final |
| Bare name `pkg`, package has no `exports` | As above, entry point from `main` or `index` | As above | As above | ESM fails, final; CommonJS continues looking for an entry point at the next layer |
| Subpath `pkg/sub`, package has `exports` | The first `pkg` found decides; if `./sub` is not exported, `ERR_PACKAGE_PATH_NOT_EXPORTED` is final | Same as ESM | As above | Final; no other copy is searched |
| Subpath `pkg/sub`, package has no `exports` | If the first `pkg` found has no `sub`, `ERR_MODULE_NOT_FOUND` is final | Tries `<layer>/pkg/sub` layer by layer: ① and ②, then the position of the package name at ③ (the generation's package directory when an entry exists), then ④ | As above | ESM final; CommonJS continues to the next layer, that is ④ |
| Subpath `pkg/sub`, package has no `exports` | If the first `pkg` found has no `sub`, `ERR_MODULE_NOT_FOUND` is final | Tries `<layer>/pkg/sub` layer by layer: ① and ②, then the position of the package name at ③ (the runtime resolution's package directory when an entry exists), then ④ | As above | ESM final; CommonJS continues to the next layer, that is ④ |
| `#alias` package-internal alias | After the owning manifest's `imports` maps it to a bare name, handled per the rows above; a mapping to a relative path goes straight to Node | Same as ESM, with conditions from the caller or the defaults | Same as the matching row | Same as the matching row |
| `require.resolve(pkg, { paths })` | No such form | Applies the same rule to each item in `paths` independently: an item inside the profile tree walks ① and ②, ③, ④ from that item; an item outside the tree goes to Node; in the caller's order | As above | The item continues to its next layer, then the next item takes its turn |
| Package self-reference (the importer's package imports by its own `name`) | Goes to Node with the original importer kept | Same as ESM | Not involved | Not applicable |
| Relative / absolute / URL / builtin | Goes to Node | Goes to Node | Not involved | Not applicable |
After a generation hit, the `ERR_MODULE_NOT_FOUND` and `ERR_PACKAGE_PATH_NOT_EXPORTED` errors Node reports replace the internal declaring location with the original importer; the CommonJS require stack also drops the internal anchor.
After a runtime resolution hit, the `ERR_MODULE_NOT_FOUND` and `ERR_PACKAGE_PATH_NOT_EXPORTED` errors Node reports replace the internal declaring location with the original importer; the CommonJS require stack also drops the internal anchor.
#### Table 2: Interception position for the Web and Desktop profiles
@@ -107,20 +107,20 @@ The plugin's own third-party dependencies are hoisted to `$DSH_HOME/profiles/<na
`npm link`, or a bare directory path such as `dsh plugin add ../my-plugin` (which pnpm treats as `link:`), makes the entry in the profile a symlink to the plugin repository. The plugin loads by its real path and the hook takes no part in its imports. The plugin repository's own dependency tree must resolve dsh installation packages to the same copy as the running dsh; both layouts below satisfy this.
- Layout A: the plugin repository installs `@deepseek-ai/dsh` and the `@deepseek-ai/dsh-*` peers the plugin uses as devDependencies and starts with the dsh in that repository, for example `pnpm exec dsh --profile <name>`. The running installation is the copy on the plugin's ancestor chain, so the generation and the real paths agree.
- Layout A: the plugin repository installs `@deepseek-ai/dsh` and the `@deepseek-ai/dsh-*` peers the plugin uses as devDependencies and starts with the dsh in that repository, for example `pnpm exec dsh --profile <name>`. The running installation is the copy on the plugin's ancestor chain, so the runtime resolution and the real paths agree.
- Layout B: the plugin repository links the dsh packages it uses to the local dsh source repository (`link:../deepseek-harness/packages/core/tools` or `pnpm link`) and starts `pnpm dsh --profile <name>` from the source repository. Both sides load the same copy from the source repository.
Not provided and not recommended: having the hook proxy installation packages for real paths linked out of the tree, which requires tracking every real root entered from the profile, something Node itself does not do for developers either; and starting with a dsh installed elsewhere while the plugin repository installs its own dsh devDependencies, which produces two module instances and splits both `Symbol()` keys and `instanceof` checks.
## Alternatives considered
**Let the generation act only as a fallback after the whole ancestor chain is exhausted.** The ancestor chain would first read a leftover historical installation link at ③ and use it, which conflicts with "installation packages come from the running installation". The generation must occupy the positions of installation package names at ③.
**Let the runtime resolution act only as a fallback after the whole ancestor chain is exhausted.** The ancestor chain would first read a leftover historical installation link at ③ and use it, which conflicts with "installation packages come from the running installation". The runtime resolution must occupy the positions of installation package names at ③.
**Treat the generation as a whole layer inserted into the ancestor chain instead of occupying package directories at ③.** The two differ in exactly one place: when CommonJS hits a generation package and the subpath is missing, the former would go back and read the old copy of the same name at ③, while the latter goes straight to ④ per Node's handling of a "found package". Occupying the package directory matches Node semantics and needs no extra check for ③.
**Treat the runtime resolution as a whole layer inserted into the ancestor chain instead of occupying package directories at ③.** The two differ in exactly one place: when CommonJS hits a runtime resolution package and the subpath is missing, the former would go back and read the old copy of the same name at ③, while the latter goes straight to ④ per Node's handling of a "found package". Occupying the package directory matches Node semantics and needs no extra check for ③.
**Give installation closure entries absolute priority over nearer copies.** The closure contains 281 third-party libraries. A plugin's private versions of `zod`, `yaml`, and others would be overridden by the versions in the installation closure, breaking the nearest-wins rule shared with Node. The supported installation flow no longer installs dsh peers into the profile, so overriding is not needed to guarantee a single instance.
**Recognize and bypass symlinks in the `.dsh-module-fallback` layout at resolve time.** This keeps dedicated logic in the lookup path for directories that are no longer produced, and every resolution pays for it. Removing those projections once at profile load reaches the same result: after a bundle is deselected while it stays installed, its projected plugin no longer shadows the same-named plugin the generation selects from another bundle.
**Recognize and bypass symlinks in the `.dsh-module-fallback` layout at resolve time.** This keeps dedicated logic in the lookup path for directories that are no longer produced, and every resolution pays for it. Removing those projections once at profile load reaches the same result: after a bundle is deselected while it stays installed, its projected plugin no longer shadows the same-named plugin the runtime resolution selects from another bundle.
**Hide the whole physical directory that holds the interception layer.** This conflicts with "all other resolution matches Node": non-installation packages placed in `$DSH_HOME/profiles/node_modules` would become invisible to every profile.
@@ -129,7 +129,7 @@ Not provided and not recommended: having the hook proxy installation packages fo
## Verification
- The table-driven matrix in [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts): importers are the profile root and a plugin inside the profile; package names are an installation entry, a bundle-only entry, and a name outside the table; every presence combination of the four layers ①②③④, with ② and ③ each in two forms, a real directory and a symlink pointing elsewhere; every cell asserts that ESM import, CommonJS require, `require.resolve`, and the `packageDir` metadata land in the same directory.
- Dedicated cases in the same file cover each row of Table 1: bare names and subpaths with and without `exports`, `#alias`, explicit `paths`, package self-reference, CommonJS skipping ③ and going straight to ④ when a subpath is missing after a generation hit, and the interception position of an out-of-tree profile.
- Dedicated cases in the same file cover each row of Table 1: bare names and subpaths with and without `exports`, `#alias`, explicit `paths`, package self-reference, CommonJS skipping ③ and going straight to ④ when a subpath is missing after a runtime resolution hit, and the interception position of an out-of-tree profile.
- The [real CLI launch test](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts), under src and lib launches and under plain-directory and npm-link layouts, places an old `@deepseek-ai/dsh-tools` link at ③ and an external package with its embedded dependency at ③, and asserts that Tools and AgentLoop share one module instance, that the external package resolves along its real path, and that every file and link target stays byte-identical.
## Consequences
@@ -12,7 +12,7 @@ profile 从自己的包项目加载插件。dsh 本体包和 bundle 内嵌的依
## Decision
profile 内模块的解析是一条普通的 Node 祖先 `node_modules` 链。dsh 只做一件事:启动时算出的 generation 中的每个包名,占据 `$DSH_HOME/profiles/node_modules/<包名>` 这个包目录的位置。链走到这一层时,有条目的包名由 generation 的包回答,没有条目的包名看这一层的物理目录。除此之外的一切解析行为与 Node 一致。[不可变 generation 的构造、发布、Worker 继承与运行时载体](2026-09-09-profile-resolution-generations.zh.md)由既有 Note 记录,本篇不复述。
profile 内模块的解析是一条普通的 Node 祖先 `node_modules` 链。dsh 只做一件事:启动时算出的 runtime resolution 中的每个包名,占据 `$DSH_HOME/profiles/node_modules/<包名>` 这个包目录的位置。链走到这一层时,有条目的包名由 runtime resolution 的包回答,没有条目的包名看这一层的物理目录。除此之外的一切解析行为与 Node 一致。[不可变 generation 的构造、发布、Worker 继承与运行时载体](2026-09-09-profile-resolution-generations.zh.md)由既有 Note 记录,本篇不复述。
### 一、解析规则
@@ -27,26 +27,26 @@ importer 指发起 `import` 或 `require` 的那个模块文件。Node 从 impor
```text
① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules 插件私有依赖
② $DSH_HOME/profiles/web/node_modules profile 安装的包
③ $DSH_HOME/profiles/node_modules 拦截层:generation 占据其中有条目的包目录
③ $DSH_HOME/profiles/node_modules 拦截层:runtime resolution 占据其中有条目的包目录
④ $DSH_HOME/node_modules
⑤ /node_modules,随后是 NODE_PATH 与全局目录
```
#### 2. 拦截层:generation 占据 `$DSH_HOME/profiles/node_modules/<包名>`
#### 2. 拦截层:runtime resolution 占据 `$DSH_HOME/profiles/node_modules/<包名>`
拦截层是 importer 所属 profile 目录(`$DSH_HOME/profiles/<name>`)之上的第一个祖先 `node_modules`。树内所有 profile 的这一层都是 `$DSH_HOME/profiles/node_modules`。
generation 中的每个包名,在这一层上占据 `<拦截层>/<包名>` 这个包目录的位置。对 `@deepseek-ai/dsh-tools` 这样的本体包名,③ 上的包目录就是运行中安装的那一份;磁盘上同名的旧链接或旧目录不再是链上的成员。对 generation 没有的包名,③ 上的包目录就是磁盘上的物理内容。
runtime resolution 中的每个包名,在这一层上占据 `<拦截层>/<包名>` 这个包目录的位置。对 `@deepseek-ai/dsh-tools` 这样的本体包名,③ 上的包目录就是运行中安装的那一份;磁盘上同名的旧链接或旧目录不再是链上的成员。对 runtime resolution 没有的包名,③ 上的包目录就是磁盘上的物理内容。
一次裸包名请求的顺序:主线先走完 profile 目录内部的各层(①②),有候选就交给 Node 在候选内解析并返回。走到 ③ 时,generation 有该包名的条目,Node 就在条目记录的包目录内解析;找到就返回,包内缺文件则按 Node 自身对"已找到包"的处理继续,CommonJS 从 ④ 继续逐层找子路径,ESM 直接报错。generation 没有条目,Node 就看 ③ 的物理目录,再到 ④。
一次裸包名请求的顺序:主线先走完 profile 目录内部的各层(①②),有候选就交给 Node 在候选内解析并返回。走到 ③ 时,runtime resolution 有该包名的条目,Node 就在条目记录的包目录内解析;找到就返回,包内缺文件则按 Node 自身对"已找到包"的处理继续,CommonJS 从 ④ 继续逐层找子路径,ESM 直接报错。runtime resolution 没有条目,Node 就看 ③ 的物理目录,再到 ④。
Node 仍然负责 `exports`、`imports`、conditions、`main`、子路径、扩展名、缓存与错误码。被选中的包因 `exports` 拒绝某个子路径时报错终局,不会换另一个同名包。
拦截层不读、不写、不删任何磁盘链接。历史残留的软链接一律按普通文件系统内容处理:本体包名在 ③ 上的位置已被 generation 占据,旧链接永远不会被读到;其余包名按主线正常看到 ③ 里的内容。profile 加载时一次性删除 dsh 0.1.5 系列发布版的 Link 后端写进 profile 的投影:profile `node_modules` 下目标位于 `<profile>/.dsh-module-fallback/node_modules` 内的软链接,以及该目录本身。pnpm 安装的包和其他软链接都保留。
拦截层不读、不写、不删任何磁盘链接。历史残留的软链接一律按普通文件系统内容处理:本体包名在 ③ 上的位置已被 runtime resolution 占据,旧链接永远不会被读到;其余包名按主线正常看到 ③ 里的内容。profile 加载时一次性删除 dsh 0.1.5 系列发布版的 Link 后端写进 profile 的投影:profile `node_modules` 下目标位于 `<profile>/.dsh-module-fallback/node_modules` 内的软链接,以及该目录本身。pnpm 安装的包和其他软链接都保留。
#### 3. 拦截层里有什么:generation 的扫描内容
#### 3. 拦截层里有什么:runtime resolution 的扫描内容
generation 在 profile 启动时一次算出,由三部分组成。
runtime resolution 在 profile 启动时一次算出,由三部分组成。
- installation 闭包:从当前运行的 dsh 包的 `package.json` 出发,沿 `dependencies` 与 `peerDependencies` 做广度优先遍历,每条边从声明它的 manifest 按 Node 规则解析,同一个包名由第一次找到的已安装包占有。当前源码树实测 539 条,其中 258 条在 `@deepseek-ai/` 作用域,281 条是第三方库。这些条目对所有 profile 生效。
- bundle-only 条目:profile 选中的、不属于闭包的 bundle,从它的 manifest 出发做同样的遍历,闭包已占有的包名不覆盖。这些条目只对选中该 bundle 的 profile 生效,用于让 Loader 从 profile 根按裸名导入 bundle 内嵌的插件。
@@ -58,26 +58,26 @@ generation 在 profile 启动时一次算出,由三部分组成。
hook 只在 importer 位于 `$DSH_HOME/profiles/**` 时参与。builtin、相对路径、绝对路径、URL 请求,以及 importer 在树外的一切请求,直接交给 Node。
ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harness 自有 Worker 都安装同一份 generation。实现位于 `packages/boot/app-boot/src/profile-resolution/resolver.ts`,generation 的构造位于 `packages/boot/app-boot/src/profile.ts`。
ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harness 自有 Worker 都安装同一份 runtime resolution。实现位于 `packages/boot/app-boot/src/profile-resolution/resolver.ts`,runtime resolution 的构造位于 `packages/boot/app-boot/src/profile.ts`。
### 二、预演
#### 表 1:ESM 与 CommonJS 在各类请求下的主线与拦截
下表以本体包 `@deepseek-ai/dsh-tools`(generation 有条目)和第三方库 `left-pad`(generation 无条目)为例;`pkg` 泛指两者。
下表以本体包 `@deepseek-ai/dsh-tools`(runtime resolution 有条目)和第三方库 `left-pad`(runtime resolution 无条目)为例;`pkg` 泛指两者。
| 请求形态 | ESM | CommonJS | ③ 上该包名的位置 | 包内缺文件时 |
|---|---|---|---|---|
| 裸包名 `pkg`,包有 `exports` | ①② 有 `pkg` 目录则 Node 在其中按 `exports` 取入口 | 同 ESM;①② 的候选目录缺入口文件时不算候选 | 有条目:generation 的包;无条目:物理目录 | 有 `exports` 的入口缺失是 `exports` 错误,终局 |
| 裸包名 `pkg`,包有 `exports` | ①② 有 `pkg` 目录则 Node 在其中按 `exports` 取入口 | 同 ESM;①② 的候选目录缺入口文件时不算候选 | 有条目:runtime resolution 的包;无条目:物理目录 | 有 `exports` 的入口缺失是 `exports` 错误,终局 |
| 裸包名 `pkg`,包无 `exports` | 同上,入口取 `main` 或 `index` | 同上 | 同上 | ESM 终局报错;CommonJS 从下一层继续找入口 |
| 子路径 `pkg/sub`,包有 `exports` | 第一个找到的 `pkg` 决定;`./sub` 未导出则 `ERR_PACKAGE_PATH_NOT_EXPORTED` 终局 | 同 ESM | 同上 | 终局,不找别的副本 |
| 子路径 `pkg/sub`,包无 `exports` | 第一个找到的 `pkg` 内没有 `sub` 则 `ERR_MODULE_NOT_FOUND` 终局 | 逐层尝试 `<层>/pkg/sub`:①②,然后 ③ 上该包名的位置(有条目即 generation 的包目录),然后 ④ | 同上 | ESM 终局;CommonJS 继续下一层,即 ④ |
| 子路径 `pkg/sub`,包无 `exports` | 第一个找到的 `pkg` 内没有 `sub` 则 `ERR_MODULE_NOT_FOUND` 终局 | 逐层尝试 `<层>/pkg/sub`:①②,然后 ③ 上该包名的位置(有条目即 runtime resolution 的包目录),然后 ④ | 同上 | ESM 终局;CommonJS 继续下一层,即 ④ |
| `#alias` 包内别名 | 所属 manifest 的 `imports` 映射到裸包名后,按上面各行处理;映射到相对路径则直接交 Node | 同 ESM,conditions 取调用方传入或默认值 | 同对应行 | 同对应行 |
| `require.resolve(pkg, { paths })` | 无此形式 | 对 `paths` 中每一项独立套用同一规则:项在 profile 树内则从该项出发走 ①②、③、④;项在树外交 Node;按调用方给出的顺序 | 同上 | 该项继续下一层,再轮到下一项 |
| 包自引用(importer 所属包按自己的 `name` import) | 交 Node,保留原 importer | 同 ESM | 不参与 | 不适用 |
| 相对 / 绝对 / URL / builtin | 交 Node | 交 Node | 不参与 | 不适用 |
命中 generation 后,Node 报出的 `ERR_MODULE_NOT_FOUND` 与 `ERR_PACKAGE_PATH_NOT_EXPORTED` 会把内部的声明位置替换回原 importer;CommonJS 的 require stack 也去掉内部锚点。
命中 runtime resolution 后,Node 报出的 `ERR_MODULE_NOT_FOUND` 与 `ERR_PACKAGE_PATH_NOT_EXPORTED` 会把内部的声明位置替换回原 importer;CommonJS 的 require stack 也去掉内部锚点。
#### 表 2:Web 与 Desktop 两种 profile 的拦截位置
@@ -107,20 +107,20 @@ ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harne
`npm link`,或 `dsh plugin add ../my-plugin` 这类裸目录路径(pnpm 按 `link:` 处理),会让 profile 里的条目成为指向插件仓库的软链接。插件以真实路径加载,hook 不参与它的 import。dsh 本体包必须由插件仓库自己的依赖树解析到与运行中 dsh 同一份,两种布局都满足这一点。
- 布局 A:插件仓库把 `@deepseek-ai/dsh` 以及插件用到的 `@deepseek-ai/dsh-*` peer 装成 devDependency,并用该仓库里的 dsh 启动,例如 `pnpm exec dsh --profile <name>`。运行中的安装就是插件祖先链上的那一份,两者的 generation 与真实路径一致。
- 布局 A:插件仓库把 `@deepseek-ai/dsh` 以及插件用到的 `@deepseek-ai/dsh-*` peer 装成 devDependency,并用该仓库里的 dsh 启动,例如 `pnpm exec dsh --profile <name>`。运行中的安装就是插件祖先链上的那一份,两者的 runtime resolution 与真实路径一致。
- 布局 B:插件仓库把用到的 dsh 包 link 到本机的 dsh 源码仓(`link:../deepseek-harness/packages/core/tools` 或 `pnpm link`),并从源码仓启动 `pnpm dsh --profile <name>`。两边加载的都是源码仓里的同一份。
不提供、不建议的做法:由 hook 为 link 出去的真实路径代理本体包,这需要追踪从 profile 进入的每个真实根目录,Node 自身也不替开发者做这件事;用别处安装的 dsh 启动,同时插件仓库另装一套 dsh devDependency,这会产生两个模块实例,`Symbol()` 键与 `instanceof` 判断都会分裂。
## Alternatives considered
**让 generation 只在主线全部走完后兜底。** 主线会先在 ③ 读到历史残留的本体链接并使用它,与"本体以当前运行的安装为准"冲突。generation 必须占据 ③ 上本体包名的位置。
**让 runtime resolution 只在主线全部走完后兜底。** 主线会先在 ③ 读到历史残留的本体链接并使用它,与"本体以当前运行的安装为准"冲突。runtime resolution 必须占据 ③ 上本体包名的位置。
**把 generation 当作插入主线的一整层,而不是占据 ③ 上的包目录。** 两者只在一处不同:CommonJS 命中 generation 的包后子路径缺失时,前者会再回头读 ③ 上同名的旧副本,后者按 Node 对"已找到的包"的处理直接到 ④。占据包目录与 Node 语义一致,也不需要为 ③ 保留任何额外判断。
**把 runtime resolution 当作插入主线的一整层,而不是占据 ③ 上的包目录。** 两者只在一处不同:CommonJS 命中 runtime resolution 的包后子路径缺失时,前者会再回头读 ③ 上同名的旧副本,后者按 Node 对"已找到的包"的处理直接到 ④。占据包目录与 Node 语义一致,也不需要为 ③ 保留任何额外判断。
**让 installation 闭包条目绝对优先于更近的副本。** 闭包含 281 个第三方库。插件私有的 `zod`、`yaml` 等版本会被安装闭包里的版本覆盖,破坏与 Node 一致的最近者优先。受支持的安装流程已经不把 dsh 的 peer 装进 profile,不需要再用覆盖来保证单实例。
**在解析时识别 `.dsh-module-fallback` 形状的软链接并绕过。** 这会为不再产生的目录在查找路径里保留专门逻辑,每次解析都要付出判断。在 profile 加载时一次性删除这些投影能得到同样的结果:一个 bundle 被停用但仍装着时,它投影出来的插件不再遮住 generation 从另一个 bundle 选出的同名插件。
**在解析时识别 `.dsh-module-fallback` 形状的软链接并绕过。** 这会为不再产生的目录在查找路径里保留专门逻辑,每次解析都要付出判断。在 profile 加载时一次性删除这些投影能得到同样的结果:一个 bundle 被停用但仍装着时,它投影出来的插件不再遮住 runtime resolution 从另一个 bundle 选出的同名插件。
**把拦截层所在的物理目录整层隐藏。** 与"其余解析与 Node 一致"冲突:放在 `$DSH_HOME/profiles/node_modules` 的非本体包会对所有 profile 不可见。
@@ -129,7 +129,7 @@ ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harne
## Verification
- [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) 中的表驱动矩阵:importer 取 profile 根与 profile 内插件,包名取 installation 条目、bundle-only 条目与表外包名,①②③④ 四层的每一种有无组合,②③ 各含真目录与指向别处的软链接两种形态;每格断言 ESM import、CommonJS require、`require.resolve` 与 `packageDir` 元数据落在同一个目录。
- 同一文件内的专项用例覆盖表 1 各行:有无 `exports` 的裸包名与子路径、`#alias`、显式 `paths`、包自引用、命中 generation 后 CommonJS 子路径缺失时跳过 ③ 直接到 ④,以及树外 profile 的拦截位置。
- 同一文件内的专项用例覆盖表 1 各行:有无 `exports` 的裸包名与子路径、`#alias`、显式 `paths`、包自引用、命中 runtime resolution 后 CommonJS 子路径缺失时跳过 ③ 直接到 ④,以及树外 profile 的拦截位置。
- [CLI 真启动测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)在 src 与 lib 两种启动、普通目录与 npm-link 两种布局下,放置 ③ 的旧 `@deepseek-ai/dsh-tools` 链接、③ 的外部包及其内嵌依赖,断言 Tools 与 AgentLoop 共用同一模块实例、外部包沿真实路径解析、所有文件与链接目标逐字节不变。
## Consequences
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: 4d19551b9c029fa173d71f4cd2ce13c3e4b42588
README.zh.md: d1d43c663a71f0da5cc03df9a139ae4bb49a9b22
README.md: d5dc26c4ed2220bf07edb5c8494a0dec067a6adb
README.zh.md: b54e67c84c060afea5fb3f9585e91b7bcc12e4a2
+8 -8
View File
@@ -58,7 +58,7 @@ The enabled `dsh-hmr` plugin watches the profile manifest and both user patch fi
Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
Before mounting profile rows, the `dsh` launcher computes one immutable package-resolution generation from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the generation through Node's ESM and CommonJS resolvers without creating fallback links.
Before mounting profile rows, the `dsh` launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links.
`sanitizeProfile(binName, profileDir, bundles)` provides filesystem recovery without loading plugins or parsing patches. Desktop uses it for native fatal recovery. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s `cordis.patch.yml` to a unique `.bak-<timestamp>` sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. The timestamp is Unix time in milliseconds; collisions append an ordinal (`-1`, `-2`, …) without changing it. It returns the backup path, or `undefined` when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry.
@@ -110,12 +110,12 @@ This section explains how the outcomes above are realized and points at the code
### Design notes
- **Profile launch data.** `ctx.profileContext` contains only profile locations, startup bundle names, parsed invocation overlays and the telemetry opt-out value. `readProfilePatches()` composes the supplied startup profile or reads current files at those locations; callers schedule and apply the result.
- **Process-local module resolution.** Runtime resolution installs one generation on Node's internal ESM and CommonJS resolvers before profile rows mount. Node still owns exports, conditions, subpaths, module caches, and error codes; routed ESM failures report the original importer instead of the internal lookup anchor. `ctx.pluginPackages` exposes package metadata from the same generation without recording Entry imports; an installed generation is authoritative even for a miss, while low-level embedders that install the service without one retain native lookup.
- **Process-local module resolution.** The launcher installs the runtime resolution as an interception on Node's internal ESM and CommonJS resolvers before profile rows mount. Node still owns exports, conditions, subpaths, module caches, and error codes; routed ESM failures report the original importer instead of the internal lookup anchor. `ctx.pluginPackages` exposes package metadata from the same runtime resolution without recording Entry imports; an installed runtime resolution is authoritative even for a miss, while low-level embedders that install the service without one retain native lookup.
- **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
- **One fallback generation.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; generation entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names atomically, while changing or removing an existing mapping requires restart.
- **Application-owned profiles.** Application-owned profiles use the same runtime generation. Resolution does not modify their `node_modules`; pnpm owns installed packages.
- **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned generation in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection.
- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate runtime resolution when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
- **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names atomically, while changing or removing an existing mapping requires restart.
- **Application-owned profiles.** Application-owned profiles use the same runtime resolution. Resolution does not modify their `node_modules`; pnpm owns installed packages.
- **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned runtime resolution in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection.
- **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success.
- **One rejection checkpoint.** `inactiveEntries` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
- **Two-stage failure labels.** Outside startup audit failures, `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error.
@@ -135,7 +135,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla
| [`src/profile-plugins.ts`](src/profile-plugins.ts) | Installed dependencies, bundle activation policy, and manifest updates |
| [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | Profile patch backup and recovery bundle activation |
| [`src/profile-resolution/`](src/profile-resolution/) | Runtime resolver, package-metadata service, and built Worker bootstrap |
| — | No runtime invariant companion is published; one registration owns each resolver generation. |
| — | No runtime invariant companion is published; one interception owns each runtime resolution. |
</details>
@@ -173,7 +173,7 @@ Boot itself changes no request prefix. `addHarnessSourceSection` places its sour
These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog.
- **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the generation bootstrap; third-party Workers and custom `vm` linkers keep native resolution.
- **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution.
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
- **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps.
+8 -8
View File
@@ -58,7 +58,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 package resolution generation。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 generation 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。
挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 runtime resolution。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。
`sanitizeProfile(binName, profileDir, bundles)` 提供文件恢复,无需加载插件或解析 patch。Desktop 在原生致命错误恢复中调用它。调用前必须停止 profile 并排除并发 profile 写入。它将 profile 的 `cordis.patch.yml` 重命名为带唯一 `.bak-<timestamp>` 后缀的同目录备份,并恢复调用方指定的 bundle 列表,保留已安装包和其他 manifest 字段。时间戳为 Unix 毫秒数;同名备份已存在时追加序号(`-1`、`-2`、……),时间戳保持不变。返回值为备份路径;patch 不存在时返回 `undefined`,缺失的 profile 不会被创建。下次启动的 profile 初始化会重新创建空 patch。home 级 patch 不变。无效 profile JSON 在修改前报错;后续错误向调用方抛出,保留已完成的修改供重试。
@@ -110,12 +110,12 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
### 设计说明
- **Profile 启动数据。** `ctx.profileContext` 只包含 profile 位置、启动时组合包名称、已解析的调用级 overlay 与遥测退出值。`readProfilePatches()` 组合传入的启动 profile,或读取这些位置上的当前文件;调用方负责调度和应用结果。
- **进程内模块解析。** runtime 解析会在挂载 profile 条目前,将一份 generation 安装到 Node 的 ESM 与 CommonJS 内部 resolver。exports、conditions、subpath、模块缓存和错误码仍由 Node 负责;路由后的 ESM 失败会报告原始 importer,而不是内部查找锚点。`ctx.pluginPackages` 从同一 generation 提供 package metadata,不记录 Entry import;安装 generation 后,即使查询未命中也以 generation 为准,仅安装服务而未提供 generation 的底层嵌入方仍使用 Node 原生查找。
- **进程内模块解析。** runtime 解析会在挂载 profile 条目前,将 runtime resolution 作为拦截安装到 Node 的 ESM 与 CommonJS 内部 resolver。exports、conditions、subpath、模块缓存和错误码仍由 Node 负责;路由后的 ESM 失败会报告原始 importer,而不是内部查找锚点。`ctx.pluginPackages` 从同一 runtime resolution 提供 package metadata,不记录 Entry import;安装 runtime resolution 后,即使查询未命中也以 runtime resolution 为准,仅安装服务而未提供 runtime resolution 的底层嵌入方仍使用 Node 原生查找。
- **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
- **唯一 fallback generation。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;generation 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 generation 可以原子增加 package name,修改或删除既有映射则要求重启。
- **应用自有 profile。** 应用自有 profile 使用相同的 runtime generation。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。
- **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 generation。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。
- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 runtime resolution。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
- **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以原子增加 package name,修改或删除既有映射则要求重启。
- **应用自有 profile。** 应用自有 profile 使用相同的 runtime resolution。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。
- **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 runtime resolution。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。
- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
- **单一 rejection 检查点。** `inactiveEntries` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
- **两阶段失败标签。** 除启动审计失败外,`boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。
@@ -135,7 +135,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
| [`src/profile-plugins.ts`](src/profile-plugins.ts) | 已安装依赖、bundle 启用策略与 manifest 更新 |
| [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | profile patch 备份与恢复 bundle 启用状态 |
| [`src/profile-resolution/`](src/profile-resolution/) | 运行时 resolver、package metadata 服务与构建后 Worker bootstrap |
| — | 不发布运行时不变式伴生入口;每个 resolver generation 只有一个 registration 所有。 |
| — | 不发布运行时不变式伴生入口;每个 runtime resolution 只有一个拦截所有。 |
</details>
@@ -173,7 +173,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 generation bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
- **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
- **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。