fix(plugin-manager): refresh runtime package resolution

This commit is contained in:
imccyu committed 2026-10-02 17:09:01 +08:00
1 parent fdacc98bf1
commit 869afc493d
26 files changed
+662 -69

No files matched your search

@@ -6,8 +6,8 @@
en: 6a4338564c01ad91
zh: 241248cff9bd4cfc
/agent-note-add-immutable-profile-resolution-generations/problem:
en: 8e192c176fe48c4c
zh: adb965d650088eb0
en: deee06bffda52a6a
zh: a4eb7c0bfebb0a7f
/agent-note-add-immutable-profile-resolution-generations/decision:
en: a3022a87337058c5
zh: 1d53a08a25ed6eb3
@@ -21,8 +21,8 @@
en: 53a9e92698ce9a5d
zh: 6124d446e8d4a532
/agent-note-add-immutable-profile-resolution-generations/decision/immutable-generations:
en: cd4e64bd80f448a2
zh: 585e1fbd8721ae99
en: 8ca32abdff273e2d
zh: e7ca1d76a9af8f80
/agent-note-add-immutable-profile-resolution-generations/decision/shared-esm-and-commonjs-rule:
en: 0e0575a04a081b52
zh: 94c74334939e6d13
@@ -33,8 +33,8 @@
en: 99308bd758859e02
zh: 35f1e7719fe80d2e
/agent-note-add-immutable-profile-resolution-generations/decision/additive-package-changes:
en: 718dfa7826783f2a
zh: 21c3d5fdfae9cb99
en: b689db324c5b59c1
zh: af5a46acbd7be8d7
/agent-note-add-immutable-profile-resolution-generations/decision/filesystem-and-runtime-carriers:
en: b0607713102a093d
zh: 2de9c33bfaa0e3e2
@@ -48,5 +48,5 @@
en: fbb17aa8fe62b1e8
zh: 2ed091fdfd995fc8
/agent-note-add-immutable-profile-resolution-generations/consequences:
en: 54cae20b2734a07e
zh: caa334f8f7a40452
en: 457a7d1e82b45fe0
zh: 800036e083a7674c
@@ -8,7 +8,7 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md)
A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement preserves existing package mappings and local package names, permits linked-root membership changes, and never mutates a live table entry by entry.
The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement preserves retained package mappings, permits removing profile packages and changing linked-root membership, and never mutates a live table entry by entry.
## Decision
@@ -75,7 +75,7 @@ Linked-root membership is immutable within a generation. A successor may add or
The router retains the real target of each successfully published link name for its lifetime, solely to validate successors. Removing a root does not erase that record or keep its directory intercepted. Re-adding the same name and target is allowed; a different target is rejected because Node caches real paths. Failed publication changes neither the current generation nor the recorded targets.
The launcher constructs one startup generation. The service accepts a complete successor, but no package-manager transaction invokes replacement in this implementation.
The launcher constructs one startup generation. The [package refresh decision](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) assigns successor publication to Plugin Manager operations.
### Shared ESM and CommonJS rule
@@ -105,9 +105,9 @@ New Workers inherit the latest published generation. Existing Workers keep the g
### Additive package changes
A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of an existing package. The caller publishes an additive successor before mounting the new Loader row; this implementation does not provide that package transaction. A mount failure may leave the package installed but inactive.
A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of a retained package. Plugin Manager publishes the successor before mounting the new Loader row. A mount failure may leave the package installed but inactive.
Changing or removing an existing runtime package mapping, or removing a recorded profile-local package name, requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
Changing an existing runtime package mapping or removing an installation mapping requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
### Filesystem and runtime carriers
@@ -152,4 +152,4 @@ Behavior tests exercise root order, transitive and peer dependencies, local and
## Consequences
Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Package mappings and local package names remain additive; linked-root membership may change without unloading modules.
Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Profile package records and linked-root membership may be removed without unloading modules; retained package identities stay unchanged.
@@ -8,7 +8,7 @@ Status: implemented
profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest(元数据清单),也无法原子表示进程内变更。
运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换保留既有包映射和本地包名,允许调整 linked root 集合,不会逐项修改正在使用的表。
运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换保持保留包的映射不变,允许移除 profile 包和调整 linked root 集合,不会逐项修改正在使用的表。
## Decision
@@ -75,7 +75,7 @@ linked root 集合在一个 generation 内不可变。后继 generation 可以
解析器在自身生命周期内保留每个已成功发布的 link 名称对应的真实目标,仅用于校验后继 generation。移除 root 不会抹掉该记录,也不会让其目录继续被拦截。同名、同目标可以重新加入;不同目标会被拒绝,因为 Node 缓存真实路径。发布失败时,当前 generation 和已记录目标均不变。
launcher 只构造启动 generation。服务接受完整的后继 generation,但本实现没有包管理器事务调用替换操作。
launcher 只构造启动 generation。[包操作刷新决策](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)规定由 Plugin Manager 的操作发布后继代。
### ESM 与 CommonJS 共用规则
@@ -105,9 +105,9 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列
### 只增加包的变更
添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何既有 package name 的目录或版本。调用方先发布只增加映射的后继 generation,再挂载新的 Loader 配置项;本实现不提供该包事务。挂载失败可以留下已安装但未启用的包。
添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何保留包的目录或版本。Plugin Manager 先发布后继 generation,再挂载新的 Loader 配置项。挂载失败可以留下已安装但未启用的包。
修改或删除既有运行时包映射,或删除已记录的 profile 本地包名,需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
修改既有运行时包映射或删除安装映射需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
### 文件系统与运行时载体
@@ -152,4 +152,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
## Consequences
runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。包映射和本地包名仍只允许新增;linked root 集合可以变化,而不卸载模块。
runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。profile 包记录和 linked root 集合可以移除,而不卸载模块;保留包的身份保持不变。
@@ -2,30 +2,36 @@
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md
/agent-note-expire-hmr-package-configuration:
en: e6905d7657387304
zh: a7e906173a82f27c
/agent-note-expire-hmr-package-configuration/problem:
en: e327f239592ffec0
zh: 339aa414b9dc8652
/agent-note-expire-hmr-package-configuration/decision:
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration:
en: ea66719b3dcdd21c
zh: 893364551594ddaf
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/problem:
en: e59e3f5c61158701
zh: 2ced78e3c84f604e
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision:
en: a15c7c13f167e5ac
zh: a15c7c13f167e5ac
/agent-note-expire-hmr-package-configuration/decision/responsibilities:
en: e72abbf8fafc89bb
zh: f6c59177696c5cd3
/agent-note-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/responsibilities:
en: 3ffd0c78fa204b04
zh: f468bab00a7ff357
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/runtime-resolution:
en: ca45b973e1968533
zh: 002f23bb70e4fed7
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/plugin-manager:
en: 9b04926c175f22e8
zh: fbf05dd4065b0738
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
en: c7de8dafe47a5587
zh: 6010a70d8451edd1
/agent-note-expire-hmr-package-configuration/decision/future-work:
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/future-work:
en: 31818f8f7f80470b
zh: ecb7ac85bb49cf6d
/agent-note-expire-hmr-package-configuration/alternatives-considered:
en: 5dab1237a312169c
zh: b24927d5badedcf7
/agent-note-expire-hmr-package-configuration/verification:
en: d1d931904beb1c9f
zh: ea7874b974e1c209
/agent-note-expire-hmr-package-configuration/consequences:
en: e557562e3f7b07bc
zh: 3cd196a63e4099b0
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/alternatives-considered:
en: 7c1c6d21570cda0d
zh: e6f8334c85f1b404
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/verification:
en: 939cedceed200cb2
zh: c7bb02d0da400c8d
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/consequences:
en: 1b9e31b4e773fac5
zh: c140ce07fc64bd55
@@ -1,4 +1,4 @@
# Agent Note: Expire HMR package configuration
# Agent Note: Refresh runtime resolution after package operations and expire HMR package configuration
Status: implemented
@@ -6,6 +6,9 @@ English | [中文](2026-09-30-profile-package-refresh-and-manifest-invalidation.
## Problem
Two pieces of Host state did not follow changes while the process ran:
- **Runtime resolution was built only at startup.** After a GUI install or enablement, nothing published the latest generation, so a bundle's private dependencies never reached the profile fallback and its bare plugins could not be found. After disablement or removal, a new generation could not remove profile-scoped entries or profile-local names, so stale records and package metadata remained. A newly installed package directory is new, and Node holds no stale cache for it; only the table was missing.
- **Package configuration and module evaluation have separate caches.** Clearing a plugin's module cache does not refresh the package.json fields that Node uses for exports, main, imports, and format detection. A package.json can also be imported as a JSON module, in which case its consumers need ordinary module reloads.
## Decision
@@ -14,8 +17,37 @@ English | [中文](2026-09-30-profile-package-refresh-and-manifest-invalidation.
| Owner | Does | Does not |
|---|---|---|
| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names | Touch any Node cache |
| plugin-manager | Calls `pluginPackages.refresh()` at fixed points of package operations | Construct resolutions or manage module and package-configuration caches |
| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules | Reload plugins for configuration-only manifests; handle `node_modules` package replacement |
### Runtime resolution
The [generation Note](2026-09-09-profile-resolution-generations.md) owns package identity and Worker inheritance rules.
Validation of a new generation in `replace()`:
- `scope: profile` entries may be removed.
- Profile-local package names may be removed.
- Retained profile entries keep their normalized directory, version, and scope, but may change declaring anchor when another selected bundle supplies the same package. Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry; a published link name cannot select another real directory.
### Plugin Manager
| Operation | Publication point |
|---|---|
| Install a new package | After pnpm succeeds and the bundle selection is saved, before reconciliation, whether or not the bundle is enabled |
| Overwrite an installed package | No publication; the result is `restart-required` |
| Enable a bundle | After the selection is saved, before reconciliation |
| Disable a bundle | With HMR, after reconciliation stops its plugins; without HMR, no publication, and running plugins keep the current table |
| Remove | After pnpm remove succeeds, inside the HMR transaction |
| Failed or cancelled install, failed removal | No publication |
`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw.
Without HMR, deselecting a startup bundle does not stop its plugins. While such a bundle remains deselected, later package operations keep the existing runtime table instead of publishing its removal. Other no-HMR installations and removals of non-running bundles still publish normally.
A successful package operation is not rolled back when publication is rejected afterwards. The result reports the failed runtime application and retains the successful disk changes. Recomputing uses the captured profile directory on disk, not synthetic in-memory layers; a computed resolution without a profile recomputes installation packages only.
### HMR package-configuration expiry
In HMR's change dispatch, a changed file named `package.json` outside `node_modules` goes to `PackageManifests.invalidate()`. A manifest in the host dependency graph still requests a host reload; one loaded as a JSON module still reloads its consumers. A configuration-only manifest schedules no module reload. Configuration-owned paths stay with their dedicated watcher. Source changes in the same batch reload afterwards.
@@ -54,6 +86,10 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
## Alternatives considered
**Read current disk on every lookup (#4703).** Resolution follows the disk, but module caches stay and running plugins do not reload. Same-URL contents do not take effect, and different URLs load a second version beside the first in one process. It also maintains its own entry resolver, bypasses custom bare-name hooks, and does not refresh runtime resolution, so GUI-installed private dependencies still cannot be found.
**Reread every observed manifest when runtime resolution publishes (#5496).** This places package-configuration expiry in table publication, decoupled from module caches: overwriting an installed package reports restart, yet the next unrelated publication makes new imports load the new version while the old plugin still runs. Package configuration is content and belongs with module caches, in HMR.
**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; those scenarios keep requiring restart.
**Reload every plugin for a package.json change.** Configuration-only manifests do not require module evaluation. Manifests actually imported as JSON modules retain ordinary dependency-driven reloads.
@@ -66,6 +102,10 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
| Coverage | Location |
|---|---|
| A new generation removes profile entries and local names and still rejects retained-entry changes | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
| GUI install, deferred enablement, disablement, removal, overwrite, failure, and cancellation, with and without HMR | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
| Captured-directory recomputation, computed installation-only refresh, and plain-data rejection | `packages/boot/app-boot/tests/profile-resolution.spec.ts`, `packages/boot/app-boot/tests/profile-resolution-service.spec.ts` |
| Sequential no-HMR operations, shared dependency declarers, and disk-success/publication-failure outcomes | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
| Expired exports, main, imports, type, scope, and nearest manifests; native-reader parity; actual CommonJS loads and retained module instances; the `node_modules` boundary; restoration | `packages/boot/hmr/tests/package-manifest.spec.ts` |
| Configuration-only manifests, JSON-module and host reloads, source reloads, same-name and shared-runtime entries, import and activation rollback, entry restarts, and the `node_modules` exclusion | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
@@ -73,6 +113,7 @@ Tests need no API key and make no model calls.
## Consequences
- With HMR, supported package operations publish the updated private dependencies and remove unused mappings after plugins stop. Without HMR, a pending startup-bundle disablement defers later publications until restart, preserving running plugins' mappings.
- In the supported thread, package.json configuration reads use the changed fields; JSON modules retain their own reload behavior. The separate module, loader-thread, and Worker limitations above still apply.
- Package configuration below expired directories is parsed in JavaScript and must keep the native reader's field and error semantics; tests compare it with the native reader on Node 22, 24, and 26.
- The implementation depends on several Node internal interfaces, and these tests must run again for Node upgrades.
@@ -1,4 +1,4 @@
# Agent Note: HMR 包配置失效
# Agent Note: 包操作刷新 runtime resolution 与 HMR 包配置失效
Status: implemented
@@ -6,6 +6,9 @@ Status: implemented
## Problem
Host 运行期间有两处状态没有随变化更新:
- **runtime resolution 只在启动时构造。** GUI 安装或启用 bundle 后,没有人发布最新代,bundle 的私有依赖进不了 profile fallback,它的 bare 插件找不到。停用、卸载后,新一代又不允许移除 profile 范围的条目和本地包名,旧记录和包元数据一直留着。新安装的包目录本身是新的,Node 对它没有旧缓存,缺的只是这张表。
- **包配置与模块求值使用独立缓存。** 清理插件的模块缓存不会刷新 Node 用于 exports、main、imports 和格式判断的 package.json 字段。package.json 也可以被作为 JSON 模块导入,此时其消费方需要普通的模块重载。
## Decision
@@ -14,8 +17,37 @@ Status: implemented
| 负责方 | 做什么 | 不做什么 |
|---|---|---|
| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名 | 不碰 Node 的任何缓存 |
| plugin-manager | 在包操作的确定时点调用 `pluginPackages.refresh()` | 不构造 resolution;不管模块缓存与包配置缓存 |
| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载 | 不因仅作为配置的 manifest 重载插件;不处理 `node_modules` 包替换 |
### runtime resolution
包身份与 Worker 继承规则由[代际说明](2026-09-09-profile-resolution-generations.zh.md)定义。
`replace()` 对新一代的校验:
- `scope: profile` 的条目可以移除。
- profile 本地包名可以移除。
- 保留的 profile 条目保持规范化目录、版本与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目;已发布的 link 名称不能换真实目录。
### plugin-manager
| 操作 | 发布时点 |
|---|---|
| 安装新包 | pnpm 成功、保存 bundle 选择之后,reconciliation 之前;无论是否立即启用都发布 |
| 覆盖已安装的包 | 不发布,结果为 `restart-required` |
| 启用 bundle | 保存选择后发布,再做 reconciliation |
| 停用 bundle | 有 HMR 时,reconciliation 让插件退出后再发布;没有 HMR 时不发布,运行中的插件继续使用原表 |
| 卸载 | pnpm remove 成功后,在 HMR 事务内发布 |
| 安装失败、取消、卸载失败 | 不发布 |
`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()` 再 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。
没有 HMR 时,取消启动 bundle 的选择不会停止它的插件。只要该 bundle 仍未选中,后续包操作就保留当前 runtime 表,不发布其移除。其他无 HMR 的安装和未运行 bundle 卸载仍正常发布。
包操作成功后若发布被拒绝,不回滚已完成的包操作。结果报告运行时应用失败,并保留成功的磁盘变更。重新计算使用捕获的磁盘 profile 目录,不继承内存合成 layers;没有 profile 的已计算 resolution 只重算安装范围的包。
### HMR 包配置失效
HMR 的文件变化分派中,文件名为 `package.json` 且不在 `node_modules` 内的变化,交给 `PackageManifests.invalidate()`。位于宿主依赖图中的 manifest 仍请求宿主重载;作为 JSON 模块加载的 manifest 仍重载消费方。仅作为配置的 manifest 不安排模块重载。配置专属路径仍由其独立 watcher 处理。同一批的源码变化随后重载。
@@ -54,6 +86,10 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
## Alternatives considered
**每次查找都读当前磁盘(#4703)。** 解析能跟上磁盘,但不清模块缓存,也不重载运行中的插件。同一 URL 时新内容不生效,不同 URL 时新旧版本在同一进程并存。它还要维护一份入口解析实现,并且绕过自定义 bare-name hook。它也没有刷新 runtime resolution,GUI 安装的私有依赖依然找不到。
**在 runtime resolution 发布时全量重读已见过的 manifest(#5496)。** 它把包配置失效放进了绑定表发布,和模块缓存脱了钩:覆盖已安装的包时报需要重启,但下一次无关的发布会让新导入拿到新版本,旧插件仍在运行。包配置缓存属于内容,应该和模块缓存由同一方负责,也就是 HMR。
**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。
**package.json 变化时重载所有插件。** 仅作为配置的 manifest 不要求模块求值。实际作为 JSON 模块导入的 manifest 保留普通的依赖驱动重载。
@@ -66,6 +102,10 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
| 覆盖面 | 位置 |
|---|---|
| 新一代可移除 profile 条目与本地包名、仍拒绝修改保留条目 | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
| GUI 安装、延迟启用、停用、卸载、覆盖、失败、取消,有无 HMR | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
| 捕获目录后的重新计算、无 profile 的已计算 resolution 刷新、纯数据拒绝 | `packages/boot/app-boot/tests/profile-resolution.spec.ts`、`packages/boot/app-boot/tests/profile-resolution-service.spec.ts` |
| 无 HMR 连续操作、共享依赖声明来源、磁盘成功但发布失败 | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
| 失效后的 exports、main、imports、type、scope、最近 package.json,原生 reader 对照,实际 CommonJS 加载及旧模块实例保留,node_modules 边界,恢复 | `packages/boot/hmr/tests/package-manifest.spec.ts` |
| 配置专用 manifest、JSON 模块与宿主重载、源码重载、同名及共享运行时的 entry、导入与激活失败回滚、entry 重启和 node_modules 排除规则 | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
@@ -73,6 +113,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
## Consequences
- 有 HMR 时,支持的包操作发布更新后的私有依赖,并在插件停止后移除不再需要的映射。没有 HMR 时,待生效的启动 bundle 停用会将后续发布延迟到重启,保留运行中插件的映射。
- 在支持的线程内,package.json 配置读取使用更新后的字段;JSON 模块保留自己的重载行为。上述模块、loader 线程及 Worker 的独立限制仍然适用。
- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 22、24、26 上与原生读取逐项比较。
- 依赖多个 Node internal 接口,Node 升级时需要重跑这些测试。
+1 -1
View File
@@ -742,7 +742,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
patches: [],
}
const resolutionOptions = { installAnchor: INSTALL_ANCHOR, home: harnessHome, profile }
const resolution = await createRuntimeResolution(resolutionOptions)
await mkdir(profileDir, { recursive: true })
const rootConfig = join(profileDir, 'cordis.yml')
await writeFile(rootConfig, '[]\n')
@@ -796,6 +795,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`)
},
})
const resolution = await createRuntimeResolution(resolutionOptions)
await ctx.plugin(PluginPackages, {
resolution,
})
+1 -2
View File
@@ -34,8 +34,7 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]')
expect(await card.getByRole('status').count()).toBe(0)
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await toggle.click()
await page.getByRole('switch', { name: 'Enable Voice input', exact: true, checked: false }).click()
await dialog.getByRole('button', { name: 'Go to setup', exact: true }).click()
await page.getByRole('button', { name: 'Download and prepare', exact: true }).waitFor()
expect(await dialog.count()).toBe(0)
+2 -2
View File
@@ -45,8 +45,8 @@
en: f4a2695cb4c8a570
zh: 89837235d2f376cf
/deepseek-ai-dsh-app-boot/understand-the-implementation/design-notes:
en: 4ac0c33806b5a5b2
zh: cdb56f3adfcb4364
en: ee79d0f190f91cdc
zh: 89e39da6aec903ab
/deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior:
en: f7bc3a8dd4b00bdd
zh: 30098428aa769ad0
+1 -1
View File
@@ -135,7 +135,7 @@ This section explains how the outcomes above are realized and points at the code
- **Package metadata.** `ctx.pluginPackages.packageOf` locates the owning package without loading code or requiring an exported `package.json`; a subpath selects its package without validating that file. The installed runtime resolution owns selection even for a miss. Low-level embedders that install the service without one retain native lookup. Display metadata uses the separate entry-aware reader described above.
- **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 Loader subtree 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 and update linked-root membership atomically within the existing package-mapping and local-name constraints.
- **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, remove profile-scoped mappings and profile-local names, and update linked-root membership atomically; retained mappings keep their directory, anchor, version, and scope. [Plugin Manager](../plugin-manager/README.md) publishes successors after package operations ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)).
- **Removing linked interception.** A successor may remove a linked root without restart. Directories outside all remaining roots use native lookup for subsequent requests, which may find a development copy or report a missing package. Existing module references and Node caches stay unchanged. Re-adding the same link name and target is allowed; pointing a previously published name at a different target is rejected even after removal ([generation rules](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md#immutable-generations)).
- **Application-owned profiles.** Application-owned profiles use the same runtime resolution. Links within the active profile directory, including pnpm store links, are not external roots even when the profile is outside the shared profiles tree. 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.
+1 -1
View File
@@ -135,7 +135,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
- **包元数据。** `ctx.pluginPackages.packageOf` 定位所属包,不加载代码,也不要求导出 `package.json`;子路径选择其所属包,不校验该文件。安装 runtime resolution 后,即使查询未命中也以其选包规则为准。仅安装服务而不提供 runtime resolution 的底层嵌入方保留原生查询。展示元数据使用上文另述的入口感知读取器。
- **两个 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 时,持有并拆卸各自的独立 Loader 子树。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、更新 linked root 集合。
- **唯一 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 本地包名、更新 linked root 集合;保留映射的目录、锚点、版本和作用域不变。[插件管理器](../plugin-manager/README.zh.md)在包操作之后发布后继代([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。
- **移除链接拦截。** 后继 generation 可以移除 linked root,无需重启。目录不再被任何剩余 root 覆盖时,后续请求使用原生查询,可能找到开发副本,也可能报告缺包。已有模块引用和 Node 缓存保持不变。同名、同目标可以重新加入;曾发布的名称改指向不同目标时,即使中间移除过也会被拒绝([generation 规则](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md#immutable-generations))。
- **应用自有 profile。** 应用自有 profile 使用相同的 runtime resolution。目标位于当前 profile 目录内的链接(包括 pnpm store 链接)不算外部 root,即使 profile 位于共享 profiles 树外。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。
- **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 runtime resolution。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。
+1
View File
@@ -56,6 +56,7 @@ declare module '@deepseek-ai/cordis' {
export {
composeEntries,
createRuntimeResolution,
ProfileRuntimeResolution,
DEFAULT_PROFILE_BUNDLES,
OPTIONAL_BUNDLES,
bundlePatchFiles,
@@ -124,11 +124,12 @@ export interface RuntimeInterception {
*/
packageDir(specifier: string, parentURL: string): string | undefined
/**
* Atomically publish a complete successor and fresh caches. Linked roots may be added or removed.
* Removed roots stop intercepting uncovered directories; existing modules and Node caches remain intact.
* @param successor - fully constructed generation retaining existing package mappings and local names.
* @throws when the profile scope or existing mappings change, local names are removed or override
* existing mappings, or a previously published link name selects a different real directory.
* Atomically publish a complete successor and fresh caches. Profile-scoped mappings, local names, and linked
* roots may be removed; removed roots stop intercepting uncovered directories. Existing modules and Node caches
* remain intact, so callers remove a package only after its plugins stop.
* @param successor - fully constructed generation retaining the installation mappings.
* @throws when the profile scope or a retained mapping changes, an installation mapping is removed, a new local name
* overrides an existing mapping, or a previously published link name selects a different real directory.
*/
replace(successor: RuntimeResolution): void
/** Restore the native resolver methods. Interceptions dispose in reverse order. */
@@ -378,6 +379,7 @@ class ResolutionRouter {
}
for (const [name, current] of this.current.entries) {
const next = entries.get(name)
if (next === undefined && current.scope === 'profile') continue
if (next === undefined
|| !sameResolution(current.packageDir, next.packageDir)
|| !sameResolution(current.declarer, next.declarer)
@@ -387,11 +389,6 @@ class ResolutionRouter {
}
}
const localPackageNames = new Set(successor.localPackageNames)
for (const name of this.current.localPackageNames) {
if (!localPackageNames.has(name)) {
throw new Error(`profile resolution: removing local package ${JSON.stringify(name)} requires a process restart`)
}
}
for (const name of localPackageNames) {
if (!this.current.localPackageNames.has(name) && this.current.entries.has(name)) {
throw new Error(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`)
@@ -11,7 +11,7 @@ import {
registerWorkerResolution,
type RuntimeInterception,
} from './resolver.ts'
import type { RuntimeResolution } from '../profile.ts'
import { ProfileRuntimeResolution, type RuntimeResolution } from '../profile.ts'
import { readPluginMeta } from '../package-meta.ts'
declare module '@deepseek-ai/cordis' {
@@ -61,10 +61,12 @@ export class PluginPackages extends Service {
private packages = new Map<string, PluginPackage | undefined>()
private readonly interception: RuntimeInterception | undefined
private disposeWorkerResolution: (() => void) | undefined
private current: RuntimeResolution | undefined
constructor(ctx: Context, config: PluginPackagesConfig = {}) {
super(ctx, 'pluginPackages')
if (config.resolution === undefined) return
this.current = config.resolution
const interception = installRuntimeInterception(config.resolution)
this.disposeWorkerResolution = registerWorkerResolution(config.resolution)
this.interception = interception
@@ -82,11 +84,24 @@ export class PluginPackages extends Service {
replace(successor: RuntimeResolution): void {
if (this.interception === undefined) throw new Error('plugin-packages: runtime resolution is not installed')
this.interception.replace(successor)
this.current = successor
this.packages = new Map()
this.disposeWorkerResolution?.()
this.disposeWorkerResolution = registerWorkerResolution(successor)
}
/**
* Publish the latest generation computed by the installed resolution through {@link replace}. Package contents
* and loaded modules are not reloaded.
* @throws when no resolution is installed, the installed one was not computed from a profile, or the successor is rejected.
*/
async refresh(): Promise<void> {
if (!(this.current instanceof ProfileRuntimeResolution)) {
throw new Error('plugin-packages: the installed runtime resolution cannot be recomputed')
}
this.replace(await this.current.computeLatestResolution())
}
/**
* Locate the package named by a specifier without requiring a package export.
* @param specifier - module specifier whose package owns the requested module.
+49 -2
View File
@@ -440,7 +440,7 @@ export interface RuntimeResolutionOptions {
*/
export async function createRuntimeResolution(
options: RuntimeResolutionOptions,
): Promise<RuntimeResolution> {
): Promise<ProfileRuntimeResolution> {
const { installAnchor, profile, home = resolveDshHome() } = options
const profilesDir = join(home, PROFILES_DIR)
const manifest = readOptionalProfileManifest(profile)
@@ -455,7 +455,7 @@ export async function createRuntimeResolution(
: collectProfileScopePackages(profile, packageNames, profileDeclarers, profileVersions)
const linkedRoots = profile === undefined ? [] : linkedProfileRoots(profile, profilesDir)
// The Promise return type is the pre-stable API; construction has no asynchronous step.
return await Promise.resolve(Object.freeze({
return await Promise.resolve(new ProfileRuntimeResolution({ installAnchor, profile, home }, {
profilesDir,
profileDir: profile?.dir,
localPackageNames: Object.freeze(localPackageNames),
@@ -473,6 +473,53 @@ export async function createRuntimeResolution(
}))
}
/** Inputs a {@link ProfileRuntimeResolution} reuses to compute its successor. */
interface ResolutionSource {
installAnchor: string
home: string
profile: Profile | undefined
}
/**
* A runtime resolution that remembers the inputs it was computed from. Worker environment data carries only its
* fields; the inputs stay private to the thread that computed it.
*/
export class ProfileRuntimeResolution implements RuntimeResolution {
readonly profilesDir: string
readonly profileDir: string | undefined
readonly localPackageNames: readonly string[]
readonly entries: readonly RuntimeResolutionEntry[]
readonly linkedRoots: readonly LinkedRoot[]
readonly #source: ResolutionSource
/**
* @param source - inputs of {@link createRuntimeResolution}, reused by {@link computeLatestResolution}.
* @param table - the computed package table.
*/
constructor(source: ResolutionSource, table: RuntimeResolution) {
this.#source = source
this.profilesDir = table.profilesDir
this.profileDir = table.profileDir
this.localPackageNames = table.localPackageNames
this.entries = table.entries
this.linkedRoots = table.linkedRoots
Object.freeze(this)
}
/**
* Compute the latest generation from the same installation, profile directory, and Harness home, rereading the
* profile's manifest, bundle selection, and installed packages. This instance is unchanged.
* @returns a new resolution for the latest generation.
*/
computeLatestResolution(): Promise<ProfileRuntimeResolution> {
const { installAnchor, home, profile } = this.#source
return createRuntimeResolution({
installAnchor, home,
...profile === undefined ? {} : { profile: loadProfileDirectory('dsh', profile.dir, installAnchor) },
})
}
}
/** Synthetic profiles used by direct callers may have no on-disk manifest. */
function readOptionalProfileManifest(profile: Profile | undefined): ProfileManifest | undefined {
if (profile === undefined) return undefined
@@ -79,7 +79,8 @@ describe('generateConfigSchema', () => {
]
const original = structuredClone(layers)
const installAnchor = join(dir, 'installation.json')
const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution').mockResolvedValue(resolution)
const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution')
.mockResolvedValue(new profileOperations.ProfileRuntimeResolution({ installAnchor: '', home: '', profile: undefined }, resolution))
const result = await generateConfigSchema(profile, layers, installAnchor)
expect(resolve).toHaveBeenCalledExactlyOnceWith({ installAnchor, profile })
expect(result['x-cordis'].entries.map(entry => [entry.path, entry.name])).toEqual([
@@ -1,6 +1,6 @@
/** Package metadata queries share the active runtime resolution. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -9,7 +9,7 @@ import { getEnvironmentData } from 'node:worker_threads'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { PluginPackages } from '../src/profile-resolution/service.ts'
import type { RuntimeResolution } from '../src/profile.ts'
import { createRuntimeResolution, initProfile, loadProfileDirectory, type RuntimeResolution } from '../src/profile.ts'
const roots: string[] = []
const contexts: Context[] = []
@@ -53,6 +53,52 @@ function resolution(
}
describe('profile package metadata service', () => {
it('refreshes the installed resolution from its own profile and installation', async () => {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-package-service-refresh-')))
roots.push(root)
const installAnchor = join(root, 'install', 'package.json')
file(installAnchor, JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.0.0' }))
const profileDir = join(root, 'profiles', 'test')
initProfile(profileDir, ['extra'])
const bundleDir = join(profileDir, 'node_modules', 'extra')
const privateDir = join(bundleDir, 'node_modules', 'metadata-lib')
file(join(bundleDir, 'package.json'), JSON.stringify({
name: 'extra', version: '1.0.0', dependencies: { 'metadata-lib': '1.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
file(join(bundleDir, 'cordis.patch.yml'), '[]\n')
const parentURL = pathToFileURL(join(profileDir, 'caller.mjs')).href
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(PluginPackages, {
resolution: await createRuntimeResolution({ installAnchor, home: root, profile: loadProfileDirectory('dsh', profileDir, installAnchor) }),
})
expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toBeUndefined()
pkg(privateDir, '1.0.0')
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as Record<string, unknown>
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { extra: '1.0.0' } }))
await ctx.pluginPackages.refresh()
expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toMatchObject({ dir: privateDir, version: '1.0.0' })
expect(createRequire(join(profileDir, 'caller.cjs')).resolve('metadata-lib')).toBe(join(privateDir, 'index.cjs'))
})
it('rejects refreshing a resolution that was not computed from a profile', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-service-plain-'))
roots.push(root)
const packageDir = join(root, 'lib')
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(PluginPackages, {
resolution: resolution(join(root, 'profiles'), join(root, 'profiles', 'test'), packageDir, pkg(packageDir, '1.0.0'), '1.0.0'),
})
await expect(ctx.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed')
const bare = new Context()
contexts.push(bare)
await bare.plugin(PluginPackages)
await expect(bare.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed')
})
it('reads translated metadata from the selected local package without importing its entry', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-localized-package-service-'))
roots.push(root)
@@ -1629,7 +1629,6 @@ describe('runtime resolution', { concurrent: false }, () => {
}).toThrow(/cannot change its profile scope/u)
registration.replace({ ...first, localPackageNames: ['new-local'] })
registration.replace({ ...first, localPackageNames: ['new-local'] })
expect(() => { registration.replace(first) }).toThrow(/removing local package/u)
const linked = { name: 'linked-plugin', realPath: join(f.root, 'work', 'a') }
const withLocal = { ...first, localPackageNames: ['new-local'] }
registration.replace({ ...withLocal, linkedRoots: [linked] })
@@ -1643,6 +1642,35 @@ describe('runtime resolution', { concurrent: false }, () => {
)).toBe(f.installed)
})
it('removes profile mappings and local names while retaining changed profile mappings as restarts', async () => {
const f = fixture()
const firstDir = join(f.root, 'bundle-a', 'node_modules', 'private-lib')
const secondDir = join(f.root, 'bundle-b', 'node_modules', 'private-lib')
pkg(firstDir, 'private-lib', 1)
pkg(secondDir, 'private-lib', 2)
const base = await resolutionOf(f)
const entry = {
name: 'private-lib', packageDir: firstDir, version: '1.0.0',
declarer: join(firstDir, 'package.json'), scope: 'profile' as const,
}
const first = { ...base, entries: [...base.entries, entry], localPackageNames: ['local-lib'] }
const registration = installRuntimeInterception(first)
registrations.push(registration)
const parent = pathToFileURL(join(f.profile.dir, 'entry.mjs')).href
expect(registration.packageDir('private-lib', parent)).toBe(firstDir)
expect(() => {
registration.replace({ ...first, entries: [...base.entries, {
...entry, packageDir: secondDir, version: '2.0.0', declarer: join(secondDir, 'package.json'),
}] })
}).toThrow(/replacing "private-lib" requires a process restart/u)
expect(registration.packageDir('private-lib', parent)).toBe(firstDir)
registration.replace(base)
expect(registration.packageDir('private-lib', parent)).toBeUndefined()
expect(() => resolveFrom('private-lib', parent)).toThrow(expect.objectContaining({ code: 'ERR_MODULE_NOT_FOUND' }))
registration.replace(first)
expect(registration.packageDir('private-lib', parent)).toBe(firstDir)
})
it('leaves non-package and out-of-scope metadata lookups to native resolution', async () => {
const f = fixture()
const outside = join(f.root, 'outside')
@@ -514,6 +514,7 @@ describe('createRuntimeResolution', () => {
expect(resolution.entries.find(entry => entry.name === 'dep-of-a')?.packageDir).toBe(join(modules, 'dep-of-a'))
expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
await expect(createRuntimeResolution({ installAnchor: anchor, home })).resolves.toEqual(resolution)
await expect(resolution.computeLatestResolution()).resolves.toEqual(resolution)
})
it('keeps selected bundle closures profile-local without overriding installation packages', async () => {
@@ -24,8 +24,8 @@
en: d5643a06f300c30a
zh: 9348b6a7676a2849
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
en: 0f3d0c6c622577c1
zh: 5a85f66e33977da3
en: 3950ea4cee438c55
zh: 02d600bb8d61d04c
/deepseek-ai-dsh-plugin-manager/further-exploration:
en: c6538243d8702a0d
zh: 04a82d923353c75f
+1 -1
View File
@@ -89,7 +89,7 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
<details>
<summary>Implementation internals — click to expand</summary>
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. Installing a new package, enabling a bundle, disabling it with HMR after its plugins stop, and a successful removal publish the profile's runtime resolution, so a bundle's private bare plugins resolve without restart and removed packages leave no records; overwriting an installed package publishes nothing and reports `restart-required` ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output.
+1 -1
View File
@@ -89,7 +89,7 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
<details>
<summary>实现细节——点击展开</summary>
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。安装新包、启用组合包、有 HMR 时停用组合包并等插件退出后、以及删除成功后,都会发布 profile 的 runtime resolution,因此组合包的私有 bare 插件无需重启即可解析,被删除的包不留记录;覆盖已安装的包不发布,并报告 `restart-required`([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。
@@ -86,6 +86,7 @@
}
},
"devDependencies": {
"chokidar": "^4.0.3",
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
@@ -452,7 +452,9 @@ export class PluginManager extends TypertRemoteService {
setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult> {
return this.change(result => this.configure(async () => {
await this.selectBundle(name, enabled)
if (enabled) await this.refreshPackages()
result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : [])
if (!enabled && this.ownerContext.get('hmr') !== undefined) await this.refreshPackages()
}), { stage: 'enable', target: name, enabled }, 'bundle')
}
@@ -571,6 +573,7 @@ export class PluginManager extends TypertRemoteService {
return this.configure(async () => {
if (options?.enabled !== false) await this.selectBundle(name, true)
if (Object.hasOwn(before, name)) return 'restart-required'
await this.refreshPackages()
if (options?.enabled !== false) result.warnings = await this.reload()
})
}, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install')
@@ -637,6 +640,7 @@ export class PluginManager extends TypertRemoteService {
if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) {
throw new Error(result.packageResult.output)
}
await this.configure(() => this.refreshPackages())
}, { stage: 'remove', target: name }, 'remove')
}
@@ -774,6 +778,10 @@ export class PluginManager extends TypertRemoteService {
return hmr === undefined ? apply() : hmr.runExclusive(apply)
}
private async refreshPackages(): Promise<void> {
await this.ownerContext.get('pluginPackages')?.refresh()
}
private async reload(requiredIds: readonly string[] = []): Promise<string[]> {
if (this.ownerContext.get('hmr') === undefined) return []
return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds)
@@ -0,0 +1,358 @@
/** GUI package operations publish profile package resolution around activation and disposal through the real Loader. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { FiberState, type Context } from '@deepseek-ai/cordis'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import {
boot, createRuntimeResolution, initProfile, loadProfileDirectory, PluginPackages,
readProfileManifest, readProfilePatches, type ProfileContext,
} from '@deepseek-ai/dsh-app-boot'
import Hmr from '@deepseek-ai/dsh-hmr'
import type { ChokidarOptions } from 'chokidar'
import { expect, it, onTestFinished, vi } from 'vitest'
import PluginManager, { type PluginInstallRequestId } from '../src/index.ts'
import * as operations from '../src/operations.ts'
// Changes in these cases come only from GUI operations, never from file notifications.
vi.mock('chokidar', async (original) => {
const actual = await original<typeof import('chokidar')>()
return { ...actual, watch: (_paths: string | string[], options: ChokidarOptions = {}) => {
const watcher = new actual.FSWatcher(options)
queueMicrotask(() => watcher.emit('ready'))
return watcher
} }
})
/** Write the `addon` bundle, whose plugin is its private bare dependency `addon-plugin`. */
function writeBundle(dir: string, version: number): void {
const bundleDir = join(dir, 'node_modules', 'addon')
const pluginDir = join(bundleDir, 'node_modules', 'addon-plugin')
rmSync(bundleDir, { recursive: true, force: true })
mkdirSync(pluginDir, { recursive: true })
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
name: 'addon', version: `${version}.0.0`, type: 'module',
dependencies: { 'addon-plugin': `${version}.0.0` }, dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
writeFileSync(join(bundleDir, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'addon', name: 'addon-plugin' }] }]))
writeFileSync(join(pluginDir, 'package.json'), JSON.stringify({
name: 'addon-plugin', version: `${version}.0.0`, type: 'module', exports: './plugin.mjs',
}))
writeFileSync(join(pluginDir, 'plugin.mjs'), `
import { appendFileSync } from 'node:fs';
appendFileSync(${JSON.stringify(join(dir, 'addon-evaluations.log'))}, ${JSON.stringify(`addon:${version}\n`)});
export function apply(ctx) {
ctx.get('packageReloadTrace').push('start:addon:${version}');
ctx.provide('addonVersion', ${version});
ctx.effect(() => () => { ctx.get('packageReloadTrace').push('stop:addon:${version}'); });
}
`)
}
/** Write `addon` and save it as a profile dependency, as `pnpm add` does. */
function installFiles(dir: string, version: number): void {
writeBundle(dir, version)
const manifest = readProfileManifest('test', dir)
manifest.dependencies = { ...manifest.dependencies, addon: `${version}.0.0` }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
}
/** Delete `addon` and drop it from the profile dependencies, as `pnpm remove` does. */
function removeFiles(dir: string): void {
const manifest = readProfileManifest('test', dir)
delete manifest.dependencies?.addon
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
rmSync(join(dir, 'node_modules', 'addon'), { recursive: true })
}
async function fixture(options: { live?: boolean; installed?: boolean; enabled?: boolean } = {}) {
const home = realpathSync.native(mkdtempSync(join(tmpdir(), 'manager-package-reload-')))
let owner: Context | undefined
onTestFinished(async () => {
await owner?.fiber.dispose()
rmSync(home, { recursive: true, force: true })
})
const dir = join(home, 'profiles', 'test')
const installAnchor = join(home, 'package.json')
writeFileSync(installAnchor, '{"name":"installation","dependencies":{}}\n')
initProfile(dir, options.installed === true && options.enabled !== false ? ['core', 'addon'] : ['core'])
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const core = join(dir, 'node_modules', 'core')
mkdirSync(core, { recursive: true })
writeFileSync(join(core, 'package.json'), JSON.stringify({
name: 'core', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
writeFileSync(join(core, 'cordis.patch.yml'), JSON.stringify([{ insert: [
{ id: 'manager', name: 'cordis:manager' },
{ id: 'sibling', name: './sibling.mjs' },
] }]))
writeFileSync(join(core, 'sibling.mjs'), `
export function apply(ctx) {
ctx.get('packageReloadTrace').push('start:sibling');
ctx.provide('packageReloadSibling', {});
ctx.effect(() => () => { ctx.get('packageReloadTrace').push('stop:sibling'); });
}
`)
const manifest = readProfileManifest('test', dir)
manifest.dependencies = {}
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
if (options.installed === true) installFiles(dir, 1)
const loaded = loadProfileDirectory('test', dir, installAnchor)
const profile: ProfileContext = {
name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor, cwd: home, home,
startedBundles: loaded.layers.map(layer => layer.packageName), overlays: [], telemetryDisabledEnv: undefined,
}
const resolution = await createRuntimeResolution({ installAnchor, profile: loaded, home })
const trace: string[] = []
const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), async (host) => {
owner = host
host.provide('profileContext', profile)
host.provide('appReady', { onReady(listener) { listener(); return () => {} } })
host.provide('packageReloadTrace', trace)
host.loader.builtins.manager = PluginManager
await host.plugin(PluginPackages, { resolution })
})
if (options.live !== false) {
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await ctx.hmr.runExclusive(async () => {})
}
const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/')
onTestFinished(() => { registry.mockRestore() })
// Publications share the plugins' trace, so their order relative to plugin starts and stops is observable.
const replace = ctx.pluginPackages.replace.bind(ctx.pluginPackages)
const published = vi.spyOn(ctx.pluginPackages, 'replace').mockImplementation((successor) => {
trace.push('publish')
replace(successor)
})
onTestFinished(() => { published.mockRestore() })
const bundleDir = join(dir, 'node_modules', 'addon')
return {
ctx, dir, trace, published, manager: ctx.pluginManager,
parentURL: pathToFileURL(join(dir, 'caller.cjs')).href,
bundleURL: pathToFileURL(join(bundleDir, 'package.json')).href,
bundleDir,
evaluationPath: join(dir, 'addon-evaluations.log'),
entry: () => [...ctx.loader.entries()].find(row => row.id === 'include:addon'),
}
}
/** Answer each pnpm run by recording it in the trace and applying `run` to the profile files. */
function mockPnpm(dir: string, trace: string[], run: (args: readonly string[]) => void, exitCode = 0) {
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
trace.push(`pnpm:${String(args[0])}`)
run(args)
return {
exitCode, output: exitCode === 0 ? 'completed' : 'fixture install failed', truncated: false, logPath: join(dir, 'pnpm.log'),
}
})
onTestFinished(() => { pnpm.mockRestore() })
return pnpm
}
it('publishes a newly installed enabled bundle before its private bare plugin starts', async () => {
const { ctx, dir, trace, published, manager, parentURL, evaluationPath, entry } = await fixture()
expect(ctx.pluginPackages.packageOf('addon', parentURL)).toBeUndefined()
mockPnpm(dir, trace, (args) => {
expect(args.slice(0, 2)).toEqual(['add', 'addon'])
installFiles(dir, 1)
})
expect(await manager.installBundle('addon', { enabled: true })).toMatchObject({ application: 'applied', changed: true })
expect(published).toHaveBeenCalledOnce()
expect(trace).toEqual(['start:sibling', 'pnpm:add', 'publish', 'start:addon:1'])
expect(entry()?.fiber?.state).toBe(FiberState.ACTIVE)
expect(ctx.get('addonVersion')).toBe(1)
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)?.version).toBe('1.0.0')
expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\n')
})
it('publishes a newly installed disabled bundle without evaluating its plugin', async () => {
const { ctx, dir, trace, published, manager, parentURL, bundleURL, evaluationPath, entry } = await fixture()
mockPnpm(dir, trace, () => { installFiles(dir, 1) })
expect(await manager.installBundle('addon', { enabled: false })).toMatchObject({ application: 'applied', changed: true })
expect(published).toHaveBeenCalledOnce()
expect(trace).toEqual(['start:sibling', 'pnpm:add', 'publish'])
expect(entry()).toBeUndefined()
expect(existsSync(evaluationPath)).toBe(false)
expect(ctx.pluginPackages.packageOf('addon', parentURL)?.version).toBe('1.0.0')
expect(ctx.pluginPackages.packageOf('addon-plugin', bundleURL)?.version).toBe('1.0.0')
})
it('publishes an enabled bundle before its plugin starts', async () => {
const { ctx, dir, trace, published, manager, parentURL, evaluationPath, entry } = await fixture()
mockPnpm(dir, trace, () => { installFiles(dir, 1) })
expect(await manager.installBundle('addon', { enabled: false })).toMatchObject({ application: 'applied' })
published.mockClear()
trace.length = 0
expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied', changed: true })
expect(published).toHaveBeenCalledOnce()
expect(trace).toEqual(['publish', 'start:addon:1'])
expect(entry()?.fiber?.state).toBe(FiberState.ACTIVE)
expect(ctx.get('addonVersion')).toBe(1)
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)?.version).toBe('1.0.0')
expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\n')
})
it('publishes a disabled bundle only after its plugin stops', async () => {
const { ctx, trace, published, manager, parentURL, entry } = await fixture({ installed: true })
expect(entry()?.fiber?.state).toBe(FiberState.ACTIVE)
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)?.version).toBe('1.0.0')
const sibling: unknown = ctx.get('packageReloadSibling')
expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied', changed: true })
expect(published).toHaveBeenCalledOnce()
expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'publish'])
expect(entry()).toBeUndefined()
expect(ctx.get('addonVersion')).toBeUndefined()
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBeUndefined()
expect(ctx.pluginPackages.packageOf('addon', parentURL)?.version).toBe('1.0.0')
expect(ctx.get('packageReloadSibling')).toBe(sibling)
})
it('keeps the running plugin and its private package lookup when a bundle is disabled without HMR', async () => {
const { ctx, trace, published, manager, parentURL, entry } = await fixture({ live: false, installed: true })
const previous = entry()?.fiber
expect(previous?.state).toBe(FiberState.ACTIVE)
expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'restart-required', changed: true })
expect(published).not.toHaveBeenCalled()
expect(entry()?.fiber).toBe(previous)
expect(previous?.state).toBe(FiberState.ACTIVE)
expect(ctx.get('addonVersion')).toBe(1)
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)?.version).toBe('1.0.0')
expect(trace).toEqual(['start:sibling', 'start:addon:1'])
})
it('removes a running bundle after its plugin stops and then publishes', async () => {
const { ctx, dir, trace, published, manager, parentURL, bundleURL, bundleDir, entry } = await fixture({ installed: true })
const previous = entry()?.fiber
expect(previous?.state).toBe(FiberState.ACTIVE)
const sibling: unknown = ctx.get('packageReloadSibling')
const pnpm = mockPnpm(dir, trace, (args) => {
expect(args).toEqual(['remove', 'addon'])
expect(entry()).toBeUndefined()
removeFiles(dir)
})
expect(await manager.removeBundle('addon')).toMatchObject({
application: 'applied', changed: true, packageResult: { exitCode: 0 },
})
expect(pnpm).toHaveBeenCalledOnce()
expect(published).toHaveBeenCalledOnce()
expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'pnpm:remove', 'publish'])
expect(previous?.uid).toBeNull()
expect(existsSync(bundleDir)).toBe(false)
expect(ctx.pluginPackages.packageOf('addon', parentURL)).toBeUndefined()
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBeUndefined()
expect(ctx.pluginPackages.packageOf('addon-plugin', bundleURL)).toBeUndefined()
expect(ctx.get('packageReloadSibling')).toBe(sibling)
expect((await manager.listBundles()).some(bundle => bundle.name === 'addon')).toBe(false)
})
it('removes and publishes a bundle that is not running without HMR', async () => {
const { ctx, dir, trace, published, manager, parentURL, bundleDir, evaluationPath } = await fixture({
live: false, installed: true, enabled: false,
})
expect(ctx.pluginPackages.packageOf('addon', parentURL)?.version).toBe('1.0.0')
const sibling: unknown = ctx.get('packageReloadSibling')
const pnpm = mockPnpm(dir, trace, (args) => {
expect(args).toEqual(['remove', 'addon'])
removeFiles(dir)
})
expect(await manager.removeBundle('addon')).toMatchObject({
application: 'restart-required', changed: true, packageResult: { exitCode: 0 },
})
expect(pnpm).toHaveBeenCalledOnce()
expect(published).toHaveBeenCalledOnce()
expect(trace).toEqual(['start:sibling', 'pnpm:remove', 'publish'])
expect(existsSync(bundleDir)).toBe(false)
expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon')
expect(ctx.pluginPackages.packageOf('addon', parentURL)).toBeUndefined()
expect(existsSync(evaluationPath)).toBe(false)
expect(ctx.get('packageReloadSibling')).toBe(sibling)
})
it.each([{ live: true }, { live: false }])('keeps the running version and publishes nothing when an installed bundle is overwritten with HMR=$live', async ({ live }) => {
const { ctx, dir, trace, published, manager, evaluationPath, entry } = await fixture({ live, installed: true })
const previous = entry()?.fiber
expect(previous?.state).toBe(FiberState.ACTIVE)
expect(ctx.get('addonVersion')).toBe(1)
mockPnpm(dir, trace, (args) => {
expect(args.slice(0, 2)).toEqual(['add', 'addon@2'])
installFiles(dir, 2)
})
expect(await manager.installBundle('addon@2', { enabled: true })).toMatchObject({ application: 'restart-required', changed: true })
expect(published).not.toHaveBeenCalled()
expect(trace).toEqual(['start:sibling', 'start:addon:1', 'pnpm:add'])
expect(entry()?.fiber).toBe(previous)
expect(previous?.state).toBe(FiberState.ACTIVE)
expect(ctx.get('addonVersion')).toBe(1)
expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\n')
})
it('publishes nothing after installation fails', async () => {
const { ctx, dir, trace, published, manager, parentURL, evaluationPath, entry } = await fixture()
mockPnpm(dir, trace, () => { installFiles(dir, 1) }, 1)
expect(await manager.installBundle('addon', { enabled: true })).toMatchObject({
application: 'failed', changed: false, error: { diagnostic: 'fixture install failed' }, packageResult: { exitCode: 1 },
})
expect(published).not.toHaveBeenCalled()
expect(trace).toEqual(['start:sibling', 'pnpm:add'])
expect(readProfileManifest('test', dir).dependencies).toEqual({})
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBeUndefined()
expect(entry()).toBeUndefined()
expect(existsSync(evaluationPath)).toBe(false)
})
it('publishes nothing after installation is cancelled', async () => {
const { ctx, dir, trace, published, manager, parentURL, evaluationPath, entry } = await fixture()
const started = Promise.withResolvers<undefined>()
const stopped = Promise.withResolvers<undefined>()
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, _args, options) => {
installFiles(dir, 1)
const signal = options.signal
if (signal === undefined) throw new Error('Installation did not supply its cancellation signal')
const abort = () => { stopped.resolve(undefined) }
signal.addEventListener('abort', abort, { once: true })
started.resolve(undefined)
try { await stopped.promise } finally { signal.removeEventListener('abort', abort) }
return { exitCode: 1, output: 'cancelled', truncated: false, logPath: join(dir, 'pnpm.log') }
})
const requestId = '9feec888-53de-4c23-8486-965e5a0367b3' as PluginInstallRequestId
const installing = manager.installBundle('addon', { enabled: true, requestId })
onTestFinished(async () => {
stopped.resolve(undefined)
await installing
pnpm.mockRestore()
})
await started.promise
expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
expect(await installing).toMatchObject({ application: 'cancelled', changed: false })
expect(published).not.toHaveBeenCalled()
expect(trace).toEqual(['start:sibling'])
expect(readProfileManifest('test', dir).dependencies).toEqual({})
expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBeUndefined()
expect(entry()).toBeUndefined()
expect(existsSync(evaluationPath)).toBe(false)
})
it('publishes a newly installed bundle without HMR and leaves its activation to the restart', async () => {
const { ctx, dir, trace, published, manager, parentURL, evaluationPath, entry } = await fixture({ live: false })
mockPnpm(dir, trace, () => { installFiles(dir, 1) })
expect(await manager.installBundle('addon', { enabled: true })).toMatchObject({ application: 'restart-required', changed: true })
expect(published).toHaveBeenCalledOnce()
expect(trace).toEqual(['start:sibling', 'pnpm:add', 'publish'])
expect(ctx.pluginPackages.packageOf('addon', parentURL)?.version).toBe('1.0.0')
expect(entry()).toBeUndefined()
expect(ctx.get('addonVersion')).toBeUndefined()
expect(existsSync(evaluationPath)).toBe(false)
})
+3
View File
@@ -1917,6 +1917,9 @@ importers:
'@deepseek-ai/dsh-user-approval':
specifier: workspace:*
version: link:../../interaction/user-approval
chokidar:
specifier: ^4.0.3
version: 4.0.3
packages/browser-use/browser-use:
devDependencies: