From d419e7e553b17799e7f6c2c8c8a96044e57a79ae Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 01:19:52 +0800 Subject: [PATCH 001/147] feat(boot): isolate external bundles and expose the booted profile Mount every external bundle layer as a cordis:contained-group entry `bundle/` whose rows carry a `/` prefix, so one community bundle that no longer loads records a failure in the root `pluginFailures` registry instead of rejecting the whole Loader transaction, and two bundles may declare the same row id. Classify layers by origin (`builtin` for the template bundle and `dsh.profile.firstParty`, `external` for profile dependencies) and let `dsh.bundle.stage: boot` or `dsh.profile.stages` opt a bundle out of isolation when it provides a service built-in rows inject; a built-in row left pending beside an isolated bundle still fails boot and names the bundle. Keep `installFailLoud` for the boot transaction only and install runtime guards afterwards: an unhandled rejection is reported and contained, an uncaught exception is reported and exits. Report nested fiber failures under built-in entries as advisory lines. Provide `ctx.profileRuntime` from the launcher (layers with trust and stage, row origin, user-disabled rows, recomposition through the root include) and probe installed packages in a child process (`probePackage`, cached under `profiles//.dsh-plugins/`). Split installation from enablement in `reconcileInstalledBundles`; the CLI keeps install-means-enable. The plugin inventory now reports `trust`, `package`, `disabledBy`, and `failure` per row and lists rows only the failure registry knows. --- ...oped-fail-loud-and-package-probe.i18n.yaml | 6 + ...boot-scoped-fail-loud-and-package-probe.md | 35 ++ ...t-scoped-fail-loud-and-package-probe.zh.md | 35 ++ ...rnal-bundles-as-contained-groups.i18n.yaml | 6 + ...04-external-bundles-as-contained-groups.md | 37 +++ ...external-bundles-as-contained-groups.zh.md | 37 +++ apps/cli/src/plugin.ts | 71 +--- apps/cli/src/profile-boot.ts | 54 ++- apps/web/tests/scaffold.ts | 3 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 + docs/architecture.zh.md | 2 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 6 + docs/capability-seams.zh.md | 6 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 37 +++ docs/subsystems/core.zh.md | 37 +++ docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 2 + docs/user/develop/basic/publish.zh.md | 2 + packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 20 +- packages/boot/app-boot/README.zh.md | 20 +- packages/boot/app-boot/src/contained-group.ts | 168 ++++++++++ .../boot/app-boot/src/external-bundles.ts | 278 ++++++++++++++++ packages/boot/app-boot/src/index.ts | 148 ++++++++- packages/boot/app-boot/src/probe.ts | 312 ++++++++++++++++++ packages/boot/app-boot/src/profile-runtime.ts | 167 ++++++++++ packages/boot/app-boot/src/profile.ts | 64 +++- packages/boot/app-boot/tests/app-boot.spec.ts | 4 +- .../app-boot/tests/contained-group.spec.ts | 247 ++++++++++++++ .../app-boot/tests/external-bundles.spec.ts | 196 +++++++++++ packages/boot/app-boot/tests/probe.spec.ts | 191 +++++++++++ .../app-boot/tests/profile-runtime.spec.ts | 113 +++++++ packages/boot/app-boot/tests/profile.spec.ts | 57 ++++ .../extensions/tool-cordis/src/api-catalog.ts | 33 ++ .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 4 +- packages/host/plugin-inventory/README.zh.md | 4 +- packages/host/plugin-inventory/package.json | 13 +- packages/host/plugin-inventory/src/index.ts | 44 ++- packages/host/plugin-inventory/src/types.ts | 32 ++ .../plugin-inventory/tests/inventory.spec.ts | 6 + packages/host/plugin-inventory/tsconfig.json | 3 + .../tests/fixtures/production-profile.ts | 3 + pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 4 + scripts/gen-doc-graphs.ts | 8 + 52 files changed, 2455 insertions(+), 99 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md create mode 100644 packages/boot/app-boot/src/contained-group.ts create mode 100644 packages/boot/app-boot/src/external-bundles.ts create mode 100644 packages/boot/app-boot/src/probe.ts create mode 100644 packages/boot/app-boot/src/profile-runtime.ts create mode 100644 packages/boot/app-boot/tests/contained-group.spec.ts create mode 100644 packages/boot/app-boot/tests/external-bundles.spec.ts create mode 100644 packages/boot/app-boot/tests/probe.spec.ts create mode 100644 packages/boot/app-boot/tests/profile-runtime.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml new file mode 100644 index 0000000000..de1ea5fc5f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-04-boot-scoped-fail-loud-and-package-probe.md +2026-09-04-boot-scoped-fail-loud-and-package-probe.md: b97bfb57ccda65aa69832db4a9772e03853328a6 +2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 0dffeb0699d8fa847110f33e1579dd4ab1227767 diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md new file mode 100644 index 0000000000..b97bfb57cc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md @@ -0,0 +1,35 @@ +# Agent Note: Fail-loud is boot-scoped, and packages are probed in a child process + +Status: implemented + +English | [中文](2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md) + +## Problem + +`installFailLoud` registered a process-wide `unhandledRejection` handler that wrote `fatal load failure` and exited, and the launcher discarded the uninstaller it returned, so the handler lived for the whole process. That is right during startup, where an unhandled rejection is a load failure nobody else will report. After boot it meant that any plugin's stray continuation — one rejected promise a community bundle forgot to await — took every session down with it, and no `uncaughtException` handler existed at all, so a synchronous throw in a timer callback crashed the process with Node's default trace and no origin. Mounting third-party code at runtime, which the plugin manager exists to do, was a bet on the process with these two defaults in place. + +Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried. + +## Decision + +**Fail-loud ends when the tree is up.** The launcher keeps `installFailLoud`'s uninstaller and calls it once `boot()` returns, then installs `installRuntimeGuards`: an unhandled rejection after boot is reported to stderr as contained and the process keeps running; an uncaught exception is reported with its origin and the process exits, as Node would, because its state is unknown. Both handlers are removed on shutdown. + +**Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean. + +**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and reports whether each is a plugin and what `Config.toJSON()` it carries. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; a package whose cordis resolves elsewhere than the harness's own is not enableable. Records are cached under the profile's `.dsh-plugins/` and invalidated by version. + +## Alternatives considered + +**Attribute a runtime rejection to the plugin that produced it and mark that plugin failed.** The right end state, but a promise carries no fiber, and cordis's effect wrappers cover only what plugins register through them. Deferred: the guard reports and contains now; attribution needs an async-context seam. + +**Import the package in the host to learn its shape.** Rejected: import runs code with the host's privileges before the user enabled anything, and a hang or a `process.exit` in a package's module scope would be the host's. + +**Keep `installFailLoud` process-wide and ask plugin authors to be careful.** Rejected: the harness cannot review the code it lets users install, and the cost of one missed `await` was every session. + +## Consequences + +A stray rejection from any plugin after boot is a stderr line, not an exit; a misbehaving package is diagnosed by its probe record before it is enabled. The probe adds one child process per install and per version change, with a 20-second default bound. Runtime attribution of rejections to plugins remains open, so the plugin list cannot yet mark the plugin a reported rejection came from. + +## Testing + +`packages/boot/app-boot/tests/contained-group.spec.ts` pins `installRuntimeGuards` on a fake process (report without exit for a rejection, exit for an exception, uninstall) and against the real process, and `warnNestedFiberFailures` on a booted tree with a throwing `ctx.inject()` continuation under a built-in entry and under a contained one. `tests/probe.spec.ts` stages packages under a temporary profile: a bundle with gated, anonymous, and nested rows and an override, a plain plugin, a library, a package that throws at import, one that carries its own cordis copy, one whose addable module is missing, a hanging child, an exiting child, an unresolvable package, and a bad Node executable; and the cache round-trip with version mismatch and a corrupt record. diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md new file mode 100644 index 0000000000..0dffeb0699 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md @@ -0,0 +1,35 @@ +# Agent Note:fail-loud 只在启动期,包在子进程里探测 + +Status: implemented + +[English](2026-09-04-boot-scoped-fail-loud-and-package-probe.md) | 中文 + +## 问题 + +`installFailLoud` 注册了一个进程级的 `unhandledRejection` 处理器,写出 `fatal load failure` 后退出,而 launcher 丢弃了它返回的卸载函数,于是这个处理器活到进程结束。启动期间这是对的:未处理的 rejection 就是没人会报告的加载失败。启动之后它意味着任何插件的漏网延续——社区组合包忘了 await 的一个被拒 promise——都会把每个会话一起拖下去,而且根本没有 `uncaughtException` 处理器,定时器回调里的一次同步 throw 会让进程带着 Node 的默认堆栈崩掉,没有来源。插件管理器存在的目的正是在运行时挂载三方代码,在这两个默认行为之下这是拿进程赌。 + +另外,不把包 import 进宿主就没法知道一个已安装的包是什么:它是声明了组合包层还是导出了插件,它的 patch 会插入哪些行,它把 `@deepseek-ai/cordis` 解析到 harness 的那份还是自己的一份——在 profile 的 hoisted linker 与 `autoInstallPeers: false` 之下这才是"依赖冲突"的真实形态——以及它的主导出带什么 `Config` schema。 + +## 决定 + +**fail-loud 在树起来时结束。** launcher 保留 `installFailLoud` 的卸载函数,在 `boot()` 返回后调用它,然后安装 `installRuntimeGuards`:启动后未处理的 rejection 以"已兜住"报告到 stderr,进程继续运行;未捕获的异常连同来源一起报告,进程退出,如 Node 所做,因为其状态未知。两个处理器在关闭时移除。 + +**嵌套失败被报告,暂不致命。** `warnNestedFiberFailures` 遍历每个 runtime 的 fiber,报告属于内置条目却不是该条目根 fiber 的 `FAILED` fiber——抛错的 `ctx.inject()` 延续,Loader 给它盖了条目的章,而激活审计从未看见它。它在启动后以提示行运行;确认随附组合没有这类失败后再并入致命审计。 + +**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程:从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,报告各自是否为插件以及携带的 `Config.toJSON()`。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;cordis 解析到 harness 自有副本之外的包不可启用。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。 + +## 考虑过的替代方案 + +**把运行时 rejection 归属到产生它的插件并把该插件标为失败。** 正确的终态,但 promise 不携带 fiber,cordis 的 effect 包装也只覆盖插件经由它注册的东西。延后:守卫现在只报告并兜住;归属需要一个 async-context seam。 + +**在宿主里 import 包来了解其形态。** 否决:import 会在用户启用任何东西之前以宿主权限运行代码,包的模块作用域里的一次挂起或 `process.exit` 就是宿主的。 + +**保持 `installFailLoud` 进程级并要求插件作者小心。** 否决:harness 无法审查它允许用户安装的代码,而漏掉一个 `await` 的代价是每个会话。 + +## 后果 + +启动后任何插件的漏网 rejection 是一行 stderr 而不是退出;行为不当的包在启用前就由其探针记录诊断出来。探针每次安装及每次版本变化增加一个子进程,默认上限 20 秒。把运行时 rejection 归属到插件仍是开放问题,因此插件列表暂时不能标出报告的 rejection 来自哪个插件。 + +## 测试 + +`packages/boot/app-boot/tests/contained-group.spec.ts` 在伪进程上钉住 `installRuntimeGuards`(rejection 报告不退出、异常退出、卸载),也对真实进程验证;并在启动后的树上钉住 `warnNestedFiberFailures`:内置条目下与受控条目下各有一个抛错的 `ctx.inject()` 延续。`tests/probe.spec.ts` 在临时 profile 下布置包:带门控行、匿名行、嵌套行与一条覆盖的组合包,普通插件,库,import 时抛错的包,自带 cordis 副本的包,可添加模块缺失的包,挂起的子进程,退出的子进程,不可解析的包,以及错误的 Node 可执行文件;还有缓存往返、版本不匹配与损坏记录。 diff --git a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml new file mode 100644 index 0000000000..4f5b1b824e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-04-external-bundles-as-contained-groups.md +2026-09-04-external-bundles-as-contained-groups.md: e9bca676ed6d45ed88b1f4124c62c22c676b45c4 +2026-09-04-external-bundles-as-contained-groups.zh.md: bb360e61e6ec9aa71b84895514937e5465119c9c diff --git a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md new file mode 100644 index 0000000000..e9bca676ed --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md @@ -0,0 +1,37 @@ +# Agent Note: External bundles mount as contained, prefixed groups + +Status: implemented + +English | [中文](2026-09-04-external-bundles-as-contained-groups.zh.md) + +## Problem + +A bundle installed with `dsh plugin add` mounted its rows exactly like the installation's own: appended to the root entry list, under the ids its patch declared, inside the one Loader transaction that builds the tree. Three consequences followed from the vendored Loader's shape. `EntryGroup.update` is all-or-nothing — one rejected row rolls the group back and rethrows — so a community plugin that no longer compiled against the current harness stopped every `dsh` surface from starting, and the diagnostic named the row, not the bundle. Entry ids are unique per tree (`tree.store`), and a `create()` that finds an existing id moves that entry under the new group and replaces its options, so two bundles that both inserted `id: hello` made the second silently take over the first. Nothing recorded which layer had inserted a row, so the plugin list could not tell a built-in row from an installed one, nor a row the user disabled from one the composition gated. + +## Decision + +**Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/`, holding the bundle's inserted rows with their ids prefixed `/`; inserts into a built-in group nest their own contained group inside the target, and the bundle's id-targeted patches are rewritten to the prefixed ids when they address its own rows and passed through when they address built-in rows. `/` rather than `:` because `:` is the Loader's nested-id separator. + +**The contained group isolates row failures.** `ContainedGroup extends Group` overrides `create()`, the one per-row step the transactional `update()` awaits: a rejected row is recorded on the root's `pluginFailures` registry — tree-wide id, declared row id, module, group, stage parsed from the Loader's wrapper, message — and the group activates without it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. One fail-safe closes the corner case where isolation would hide a broken core: a built-in row left pending while any bundle is isolated still fails the boot, and the diagnostic names the isolated bundles and the `stage: boot` escape. + +**`stage: boot` is the explicit opt-out.** A bundle whose rows provide a service built-in rows inject declares `dsh.bundle.stage: boot` in its manifest, or the deployer sets `dsh.profile.stages` in the profile manifest, which wins; such a layer mounts unwrapped and unprefixed with fatal semantics. An unknown stage value fails profile loading. + +**Installed and enabled are two facts.** `reconcileInstalledBundles` no longer appends every bundle-declaring dependency to `dsh.profile.bundles` unconditionally; `autoEnable` keeps the CLI's install-and-enable semantics, and `enableBundle`/`disableBundle` are the manifest operations a plugin manager calls. `dependencies` records the install, `bundles` the enabled layers. + +**Provenance is a launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the booted profile, attributes each row to the layer that inserted it (`originOf`, prefixed ids included), reads which rows the user patch files disable with a literal `disabled: true`, and recomposes the tree through the root include — the same path the patch watchers take. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing rows the registry alone knows. + +## Alternatives considered + +**Add an `optional` field to `EntryOptions` and teach the vendored `EntryGroup.update` to skip it.** Precise, but a vendored divergence to log and re-apply on every sync, a new metadata field for `verify-cordis-config` to admit, and it would still not give a bundle an identity in the tree. Rejected: the Loader's own `builtins` seat already lets the launcher substitute the group class, and one group per bundle is the identity every later feature — a package-level switch, per-package failure reporting — needs. + +**A second boot phase for external bundles, mounted by a runtime plugin after the built-in tree is up.** Cleanest isolation and no composition rewriting, but every bundle that provides a built-in seam service would need an explicit escape anyway, sessions could start before the second phase landed, and the browser roster would need recomputation. Rejected as the larger change for the same outcome; the contained group keeps one boot transaction. + +**Keep ids unprefixed and rely on authors choosing unique ids.** Rejected because the failure mode is silent takeover, not an error. + +## Consequences + +A community bundle that breaks on a harness upgrade no longer stops `dsh`; the plugin list shows the failed row with its stage and message, and the process keeps serving. Two bundles may declare the same row id. A user patch that targets an external bundle's row must name the prefixed id, and `--dump-config` shows the group; a `!!js` disabled expression that compared `e.options.id` to the bundle's own unprefixed id no longer matches — comparing `e.options.name` is the documented form. A bundle's override of a built-in row stays outside isolation, because it edits that row in place. The nested-fiber audit that would catch a failed `ctx.inject()` continuation under a built-in entry ships as advisory lines; making it fatal waits on a pass over shipped compositions. + +## Testing + +`packages/boot/app-boot/tests/external-bundles.spec.ts` pins the composition (grouping, prefixing, patch rewriting, nested inserts, no mutation of the layer's patches) and the manifest operations. `tests/contained-group.spec.ts` boots real trees: a failing contained row is recorded while its siblings and the built-in rows run, a pending contained row is recorded, a built-in failure still rejects, a built-in row left pending beside an isolated bundle rejects and names it, and a contained row that fails on a later reload is recorded by the audit. `tests/profile.spec.ts` pins trust and stage resolution including the deployer override and the unknown-stage rejection; `tests/profile-runtime.spec.ts` pins provenance and recomposition; `packages/host/plugin-inventory/tests/inventory.spec.ts` pins the new row fields. diff --git a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md new file mode 100644 index 0000000000..bb360e61e6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md @@ -0,0 +1,37 @@ +# Agent Note:外部组合包挂载为受控的、带前缀的组 + +Status: implemented + +[English](2026-09-04-external-bundles-as-contained-groups.md) | 中文 + +## 问题 + +用 `dsh plugin add` 安装的组合包,其行的挂载方式与安装自带的行完全一样:追加到根条目列表,用它的 patch 声明的 id,位于构建整棵树的那一个 Loader 事务里。vendored Loader 的形态由此带来三个后果。`EntryGroup.update` 是整组事务——一行被拒就回滚整组并重新抛出——于是一个不再能在当前 harness 上编译的社区插件会让每一个 `dsh` 表面都起不来,而诊断信息点名的是行,不是组合包。entry id 在整棵树内唯一(`tree.store`),`create()` 发现已有 id 时会把那个 entry 挪到新组之下并替换其 options,于是两个都插入 `id: hello` 的组合包,第二个会静默接管第一个。没有任何记录说明某一行由哪一层插入,因此插件列表既分不出内置行与已安装行,也分不出用户停用的行与组合门控的行。 + +## 决定 + +**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/`,其中放着组合包插入的行,id 带 `/` 前缀;插入内置组的行在目标组内嵌套自己的受控组,组合包按 id 定位的 patch 在指向自己的行时改写为前缀后的 id,在指向内置行时原样通过。用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。 + +**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。 + +**`stage: boot` 是显式的退出隔离。** 若组合包的行提供内置行所注入的服务,作者在 manifest 里声明 `dsh.bundle.stage: boot`,或部署者在 profile manifest 里设置 `dsh.profile.stages`,后者优先;这样的层不包组、不加前缀、按致命语义挂载。未知的 stage 值让 profile 加载失败。 + +**安装与启用是两件事。** `reconcileInstalledBundles` 不再无条件把每个声明了组合包的依赖追加进 `dsh.profile.bundles`;`autoEnable` 保留 CLI 装即启用的语义,`enableBundle`/`disableBundle` 是插件管理器调用的 manifest 操作。`dependencies` 记录安装,`bundles` 记录已启用的层。 + +**来源是 launcher 的服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已启动的 profile,把每一行归属到插入它的层(`originOf`,含前缀 id),读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并经根 include 重新组合整棵树——patch 监视器走的正是同一条路。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出只有注册表知道的行。 + +## 考虑过的替代方案 + +**给 `EntryOptions` 加 `optional` 字段并教 vendored 的 `EntryGroup.update` 跳过它。** 精确,但是一处每次同步都要登记并重新施加的 vendored 分歧,是 `verify-cordis-config` 要放行的新元数据字段,而且仍然不能给组合包在树里一个身份。否决:Loader 自己的 `builtins` 位置已经允许 launcher 替换组类,而"每个组合包一个组"正是之后每个功能——包级开关、按包报告失败——所需要的身份。 + +**为外部组合包增加第二个启动阶段,由运行时插件在内置树起来之后挂载。** 隔离最干净,也不用改写组合,但每个提供内置 seam 服务的组合包反正都需要显式逃生口,会话可能在第二阶段落地前就开始,浏览器 roster 也要重算。否决:同样的结果却是更大的改动;受控组保住了单一启动事务。 + +**保持 id 不加前缀,依赖作者自己选唯一 id。** 否决,因为失败形态是静默接管,不是报错。 + +## 后果 + +harness 升级后损坏的社区组合包不再让 `dsh` 停下;插件列表显示失败的行及其阶段与消息,进程继续服务。两个组合包可以声明同名行 id。针对外部组合包某行的用户 patch 必须写前缀后的 id,`--dump-config` 会显示该组;把 `e.options.id` 与组合包自己未加前缀 id 比较的 `!!js` disabled 表达式不再匹配——比较 `e.options.name` 是文档化的写法。组合包对内置行的覆盖留在隔离之外,因为它原地修改那一行。能抓住内置条目下失败的 `ctx.inject()` 延续的嵌套 fiber 审计以提示行交付;改为致命要等对随附组合做一轮检查。 + +## 测试 + +`packages/boot/app-boot/tests/external-bundles.spec.ts` 钉住组合(分组、前缀、patch 改写、嵌套插入、不改动层自己的 patch)与 manifest 操作。`tests/contained-group.spec.ts` 启动真实的树:受控行失败被记录而其兄弟行与内置行运行,pending 的受控行被记录,内置失败仍然 reject,被隔离组合包旁边停在 pending 的内置行 reject 并点名它,稍后重载时失败的受控行由审计记录。`tests/profile.spec.ts` 钉住 trust 与 stage 的解析,包括部署者覆盖与未知 stage 的拒绝;`tests/profile-runtime.spec.ts` 钉住来源与重新组合;`packages/host/plugin-inventory/tests/inventory.spec.ts` 钉住新的行字段。 diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 70741703c4..e0902db90f 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -18,9 +18,8 @@ import { initProfile, PROFILE_TEMPLATES, readProfileManifest, - resolveBundleDir, + reconcileInstalledBundles, resolveProfileDir, - writeProfileManifest, type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' import { INSTALL_ANCHOR } from './profile-boot.ts' @@ -28,66 +27,22 @@ import { INSTALL_ANCHOR } from './profile-boot.ts' const NAME = 'dsh' /** - * Whether a resolved dependency exports a profile patch, i.e. is a bundle. - * @param packageName - the dependency's package name. - * @param profileDir - the profile directory (resolution anchor). - * @returns true when the package manifest declares `dsh.bundle`. - */ -function exportsPatch(packageName: string, profileDir: string): boolean { - let dir: string - try { - dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir) - } catch { - return false // pnpm reported success yet the package is unresolvable — treat as plain - } - const manifest = readProfileManifest(NAME, dir) - return manifest.dsh?.bundle?.patch !== undefined -} - -/** - * Reconcile `dsh.profile.bundles` against the installed state: pnpm has - * already written the real installed names (so a git/path/tarball/alias spec - * on the command line reconciles by its true package name) and materialized - * the packages. A dependency that resolves to a `dsh.bundle`-declaring - * package joins the layer stack (appended in dependency order); a - * dependency-listed name that no longer does — removed, or the installed - * version dropped the declaration — leaves it. In-box bundles from the - * profile template are not dependencies and are never touched. Warns once - * per newly-added bundle-less dependency (a plain library is fine; the + * Reconcile `dsh.profile.bundles` against the installed state with the CLI's + * install-and-enable semantics: pnpm has already written the real installed + * names (so a git/path/tarball/alias spec on the command line reconciles by + * its true package name) and materialized the packages, and every newly + * installed bundle joins the layer stack. Warns once per newly-added + * bundle-less dependency (a plain library or plugin module is fine; the * warning is orientation). */ function reconcilePlugins(before: ProfileManifest, profileDir: string): void { - const after = readProfileManifest(NAME, profileDir) - const beforeDeps = new Set(Object.keys(before.dependencies ?? {})) - const dependencies = Object.keys(after.dependencies ?? {}) - const plugins = after.dsh?.profile?.bundles ?? [] - let changed = false - for (const packageName of dependencies) { - const isBundle = exportsPatch(packageName, profileDir) - if (isBundle && !plugins.includes(packageName)) { - plugins.push(packageName) - changed = true - } else if (!isBundle && !beforeDeps.has(packageName)) { - process.stderr.write( - `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer ` - + '(a later update that gains one activates it automatically)\n', - ) - } + const outcome = reconcileInstalledBundles(NAME, profileDir, INSTALL_ANCHOR, before, { autoEnable: true }) + for (const packageName of outcome.plain) { + process.stderr.write( + `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer ` + + '(a later update that gains one activates it automatically)\n', + ) } - const dependencySet = new Set(dependencies) - for (const packageName of [...plugins]) { - // Only dependency-managed entries are subject to removal; template - // bundles (dsh-base and friends) are not dependencies. - const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName) - const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir) - if (wasDependency && !stillBundle) { - plugins.splice(plugins.indexOf(packageName), 1) - changed = true - } - } - if (!changed) return - after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } } - writeProfileManifest(profileDir, after) } /** diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 1b12218ab5..9b9c48683a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -20,14 +20,20 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, + composeExternalLayer, healProfilesModuleFallback, installFailLoud, + installRuntimeGuards, loadOptionalPatches, loadOverlayPatches, loadProfile, PROFILE_PATCH_FILENAME, + ProfileRuntime, + rootIncludeEntry, + warnNestedFiberFailures, watchUserPatches, type Profile, + type ProfileLayer, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' @@ -143,6 +149,18 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { ] } +/** + * The patches one bundle layer contributes. A built-in layer, or an external + * layer the profile stages at boot, mounts its patches as written; every other + * external layer mounts as one contained, id-prefixed group. + * @param layer - the resolved layer. + * @returns the layer's patches in application order. + */ +export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] { + if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches + return layer.patches +} + /** * Load `name` and compose its effective patch stack: bundle layers in * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's @@ -162,7 +180,7 @@ async function composeProfile( await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile }) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) - const bundlePatches = profile.layers.flatMap(layer => layer.patches) + const bundlePatches = profile.layers.flatMap(bundleLayerPatches) const rows = new Map() for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) @@ -218,9 +236,11 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ) const composed = await composeProfile(options.profile, options.patchFiles) - const app: { current?: Context } = {} + const app: { current?: Context; runtime?: ProfileRuntime } = {} const appReady = createAppReady() + let uninstallRuntimeGuards = (): void => {} const shutdown = createProcessShutdown(async () => { + uninstallRuntimeGuards() await app.current?.fiber.dispose() await disposeProxy() }) @@ -236,7 +256,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // complete; SIGINT is a user interrupt and reports 130. process.on('SIGTERM', () => { interrupt(0) }) process.on('SIGINT', () => { interrupt(130) }) - installFailLoud(NAME, process, async () => { + const uninstallFailLoud = installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() }) @@ -253,12 +273,15 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // objects in place. Reusing one parsed patch object across applications // would bake a user override into the bundle's in-memory insert row, so // removing the override could never revert the row to the bundle default. - const composeLive = (): PatchOptions[] => structuredClone([ - ...composed.bundlePatches, - ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], + const composeFor = (profile: Profile): PatchOptions[] => structuredClone([ + ...profile.layers.flatMap(bundleLayerPatches), + ...loadOptionalPatches(NAME, profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlays, ]) + // Once the profile runtime is mounted its profile is the current one: a + // bundle enabled since boot lives only there. + const composeLive = (): PatchOptions[] => composeFor(app.runtime?.current ?? composed.profile) // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { @@ -275,6 +298,25 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con }) }) app.current = ctx + // The tree is up: a later unhandled rejection is a plugin's stray + // continuation, not a load failure, and must not take every session down. + uninstallFailLoud() + uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) }) + if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) { + warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) }) + await ctx.plugin(ProfileRuntime, { + profile: composed.profile, + loadProfile: () => prepareProfile(options.profile), + compose: composeFor, + rootEntry: () => rootIncludeEntry(ctx), + readUserPatches: () => [ + ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], + ...loadOptionalPatches(NAME, homePatchPath()) ?? [], + ], + }) + const runtime = ctx.get('profileRuntime') + if (runtime !== undefined) app.runtime = runtime + } // A live-reload profile can dispose the whole tree while post-boot watcher // setup is in flight — a signal or appExit. Loader presence and fiber state // own liveness; the initial check skips a tree that already exited, and the diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a6217bc5ff..024ac5b49e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -635,8 +635,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise`, with ids prefixed `/`, and a row that fails to start is isolated and reported in the plugin list while built-in rows keep failing the boot. A bundle that provides a service built-in rows inject opts out of isolation with `dsh.bundle.stage: boot`, and an isolated failure that leaves a built-in row waiting still stops the boot and names the bundle. Installing a package and enabling its layer are separate facts: `dependencies` records the install, `dsh.profile.bundles` records the enabled layers. + Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle. To see the tree your machine boots: diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 9666f3589c..246546518f 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -26,6 +26,8 @@ 各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。 +用 `dsh plugin` 装进 profile 的组合包是外部的。它的行挂在一个受控组 `bundle/` 下,id 带 `/` 前缀;启动失败的行被隔离并在插件列表里报告,而内置行仍然让启动失败。提供内置行所注入服务的组合包用 `dsh.bundle.stage: boot` 退出隔离;隔离的失败若让某个内置行停在等待状态,启动仍会失败并点名该组合包。安装一个包和启用它的层是两件事:`dependencies` 记录安装,`dsh.profile.bundles` 记录已启用的层。 + 自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。 要查看你的机器启动的配置树: diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 795a426436..c969bb9c9f 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: 83868afe952c5dbc179114ff52228fc1dc8b8fdb -capability-seams.zh.md: 064797bbc3e5d71c0bdb0b2afe09560577a1b029 +capability-seams.md: 409315b7f15a124511db1baf9e28ed440006ad72 +capability-seams.zh.md: 33338ea631df091a16c7a5fea79a6cb12d5af092 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 83868afe95..409315b7f1 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -106,6 +106,9 @@ flowchart LR svc_planMode["ctx.planMode
Plan collaboration state"] pkg_agent_presets["agent-presets"] svc_agentPresets["ctx.agentPresets
Per-session agent composition"] + pkg_app_boot["app-boot"] + svc_profileRuntime["ctx.profileRuntime
Booted profile facts and recomposition"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] pkg_session_projection["session-projection"] @@ -230,6 +233,7 @@ flowchart LR pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController + pkg_app_boot --> svc_profileRuntime pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments pkg_authorization --> svc_authorization @@ -376,6 +380,7 @@ flowchart LR svc_llm --> pkg_agent_loop svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp + svc_profileRuntime --> pkg_host_plugin_inventory svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -500,6 +505,7 @@ flowchart LR | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | +| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 064797bbc3..33338ea631 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -108,6 +108,9 @@ flowchart LR svc_planMode["ctx.planMode
Plan collaboration state"] pkg_agent_presets["agent-presets"] svc_agentPresets["ctx.agentPresets
Per-session agent composition"] + pkg_app_boot["app-boot"] + svc_profileRuntime["ctx.profileRuntime
Booted profile facts and recomposition"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] pkg_session_projection["session-projection"] @@ -232,6 +235,7 @@ flowchart LR pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController + pkg_app_boot --> svc_profileRuntime pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments pkg_authorization --> svc_authorization @@ -378,6 +382,7 @@ flowchart LR svc_llm --> pkg_agent_loop svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp + svc_profileRuntime --> pkg_host_plugin_inventory svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -502,6 +507,7 @@ flowchart LR | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | +| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | 由 profile launcher 在启动后提供:已组合的组合包层及其 trust 与 stage、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7eb463168f..6a83eb36c2 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 6a5e4232f5a4da309b48c2e7c00dadf09e8b308a -module-graph.zh.md: 2388a1d9843e5e2fe308e372f9d7db8150b48160 +module-graph.md: 7dfe9a3b312c141e4df4c74f96a78deeade9b718 +module-graph.zh.md: 4e70a6a9baeda7d379672d4aa102806f9e816d3e diff --git a/docs/module-graph.md b/docs/module-graph.md index 6a5e4232f5..7dfe9a3b31 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -920,6 +920,7 @@ flowchart TD pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_app_boot pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol pkg_tool_bash --> pkg_agent @@ -1374,7 +1375,7 @@ flowchart TD | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2388a1d984..4e70a6a9ba 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -922,6 +922,7 @@ flowchart TD pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_app_boot pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol pkg_tool_bash --> pkg_agent @@ -1376,7 +1377,7 @@ flowchart TD | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index da1f18030d..bad358e770 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -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 docs/subsystems/core.md -core.md: 2f907062e89b9af12cd1b47d52d195866df62254 -core.zh.md: a5649032b81cf4adf49253f74c46c46beb800969 +core.md: 70d12007ba1675cece6f0ed9c86ac89fcdfebf4e +core.zh.md: 2bc19d2d1dbea704567de86494da3696d02705bd diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 2f907062e8..70d12007ba 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -838,6 +838,43 @@ roots(): Agent[] Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) + + +### `ctx.profileRuntime` — `ProfileRuntime` + +Facts and recomposition of the booted profile. + +```ts cordis-catalog +/** + * Where one mounted row came from. + * @param rowId - the row's tree-wide id. + * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row). + */ +originOf(rowId: string): RowOrigin | undefined + +/** + * Row ids the user patch layers disable with a literal `disabled: true`. + * A `!!js` gate in a user file is a condition, not a user decision, and is + * left to the composition. + * @returns the ids, re-read from disk on every call. + */ +userDisabledRowIds(): Set + +/** + * Recompose the host tree from the profile's layers and the user patch files + * as they stand now. The root Include re-applies the stack transactionally: + * a row whose options changed is updated in place, a row that appeared is + * created, a row that vanished is disposed, and a failure rolls the whole + * update back with the previous tree still running. + * @param options - `reloadBundles` re-reads the profile manifest first, so a + * bundle enabled or installed since boot joins the stack. + * @throws when the root include is not mounted, or the Loader rejected the update. + */ +async recompose(options: { reloadBundles?: boolean } = {}): Promise +``` + +Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts) + ### `agent/*` events diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index a5649032b8..2bc19d2d1d 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -848,6 +848,43 @@ roots(): Agent[] Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) + + +### `ctx.profileRuntime` — `ProfileRuntime` + +Facts and recomposition of the booted profile. + +```ts cordis-catalog +/** + * Where one mounted row came from. + * @param rowId - the row's tree-wide id. + * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row). + */ +originOf(rowId: string): RowOrigin | undefined + +/** + * Row ids the user patch layers disable with a literal `disabled: true`. + * A `!!js` gate in a user file is a condition, not a user decision, and is + * left to the composition. + * @returns the ids, re-read from disk on every call. + */ +userDisabledRowIds(): Set + +/** + * Recompose the host tree from the profile's layers and the user patch files + * as they stand now. The root Include re-applies the stack transactionally: + * a row whose options changed is updated in place, a row that appeared is + * created, a row that vanished is disposed, and a failure rolls the whole + * update back with the previous tree still running. + * @param options - `reloadBundles` re-reads the profile manifest first, so a + * bundle enabled or installed since boot joins the stack. + * @throws when the root include is not mounted, or the Loader rejected the update. + */ +async recompose(options: { reloadBundles?: boolean } = {}): Promise +``` + +Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts) + ### `agent/*` events diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 99f24a191b..39a195aa59 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -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 docs/user/develop/basic/publish.md -publish.md: 89a28e2e44a5171a3f5c48c003f5af5ec2e9e750 -publish.zh.md: 590fee6f035af3009e25bf3669ba4321951f7d07 +publish.md: 816b36ac99695c95332ae99439c1ccfc3c4a8732 +publish.zh.md: c3918913d6cb09b212eae35902a375539bb047cb diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 89a28e2e44..816b36ac99 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -109,6 +109,8 @@ dsh --profile demo `dsh plugin --profile demo remove dsh-hello-plugin` removes both the dependency and the layer. +An installed bundle mounts as an external layer: the dump shows its rows inside one group named `bundle/dsh-hello-plugin`, and each row id carries the package prefix, so the row above is `dsh-hello-plugin/hello` in the mounted tree — a user patch that targets it names that id. A row of yours that fails to start is isolated and reported in the plugin list instead of stopping `dsh`; if your bundle provides a service the built-in rows inject, declare `dsh.bundle.stage: boot` so it mounts like a built-in one and fails loud. Prefixing does not rewrite string literals, so a `!!js` disabled expression that compares `e.options.id` to your own id should compare `e.options.name` instead. + ## The loading order The effective configuration composes over an empty root by applying, in order: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 590fee6f03..c3918913d6 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -109,6 +109,8 @@ dsh --profile demo `dsh plugin --profile demo remove dsh-hello-plugin` 会同时移除依赖和对应的层。 +已安装的组合包作为外部层挂载:dump 会把它的行显示在一个名为 `bundle/dsh-hello-plugin` 的组里,每个行 id 带上包名前缀,因此上面那一行在挂载后的树里是 `dsh-hello-plugin/hello`——针对它的用户 patch 要写这个 id。你的某一行启动失败时会被隔离并在插件列表里报告,而不是让 `dsh` 停下;如果你的组合包提供内置行所注入的服务,请声明 `dsh.bundle.stage: boot`,让它像内置行一样挂载并明确失败。前缀不会改写字符串字面量,因此用 `e.options.id` 与自己 id 比较的 `!!js` disabled 表达式应改为比较 `e.options.name`。 + ## 加载顺序 生效配置在空根之上按以下顺序逐层组合: diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aa898932cf..cfcd529dc2 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -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: ddbcc900fcbb38e83b35f517f9d38cf0e04927f3 -README.zh.md: 184e1a086d09bf6807fd8fb2d17f7157ba8deaa6 +README.md: 0190a9e1d4c4b6d775aa84ee608c763555acbcce +README.zh.md: 6b3da08c8cbf5dcc68bf3d4991dda06c170c72af diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index ddbcc900fc..0190a9e1d4 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -54,6 +54,10 @@ Your machine-local preferences also live in the Harness home: Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. +A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/`, every row id is prefixed `/`, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled. + +After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one a runtime bundle enable or install uses. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits. + ### Previewing the effective configuration Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump. @@ -81,7 +85,10 @@ This section explains how the outcomes above are realized and points at the code ### Design notes - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution. -- **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. +- **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution. +- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing and entry ids are unique per tree, so two external bundles that insert the same id would otherwise make the second silently take over the first's entry. `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group`, prefixes its ids with the package name, and rewrites the bundle's own patches to the prefixed ids; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending. +- **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines. +- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason. - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. - **One rejection checkpoint.** `assertEntriesActivated` 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.** `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 so the startup diagnostic preserves the original activation error instead of only the wrap chain. @@ -94,8 +101,12 @@ The exports each own one stage of the boot: config resolution and snapshot repla | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section | -| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback | +| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit, patch parsing, config dump, harness-source section | +| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with trust and stage, module fallback | +| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, id prefixing, patch rewriting) and the manifest operations behind install, enable, and disable | +| [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry | +| [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: profile facts, row provenance, user-disabled rows, recomposition | +| [`src/probe.ts`](src/probe.ts) | The child-process package probe and its per-profile cache | | — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. | @@ -137,6 +148,9 @@ These limits describe when this boot library is a poor fit or needs special care - **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. +- **An external bundle's overrides are not isolated** — a patch it applies to a built-in row edits that row in place, so its effect stays when the bundle's own rows fail and it is the one thing a bundle can break outside its group. +- **Id prefixing does not rewrite `!!js` literals** — a bundle whose disabled expression compares `e.options.id` to its own unprefixed id keeps the literal; compare `options.name` instead. +- **The nested-fiber audit is advisory** — a failed `ctx.inject()` continuation under a built-in entry is reported, not fatal, until shipped compositions are known clean. ### Dev Note diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 184e1a086d..6b3da08c8c 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -54,6 +54,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。 +用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/` 的受控组下,每个行 id 都加上 `/` 前缀,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。 + +树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,运行时启用或安装组合包也走它。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。 + ### 预览生效配置 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。 @@ -81,7 +85,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head ### 设计说明 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。 -- **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 +- **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 +- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,entry id 在整棵树内唯一,因此两个插入同名 id 的外部组合包会让第二个静默接管第一个的 entry。`composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行包进一个 `cordis:contained-group`,给 id 加上包名前缀,并把组合包自己的 patch 改写到前缀后的 id;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。 +- **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。 +- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 @@ -94,8 +101,12 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 | -| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 | +| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计、patch 解析、配置 dump、harness 源码段落 | +| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 trust 与 stage 的组合包解析、模块后备机制 | +| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、id 前缀、patch 改写)与安装、启用、停用背后的 manifest 操作 | +| [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 | +| [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:profile 事实、行来源、用户停用的行、重新组合 | +| [`src/probe.ts`](src/probe.ts) | 子进程包探针及其按 profile 的缓存 | | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 | @@ -137,6 +148,9 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 - **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 +- **外部组合包的覆盖不被隔离**——它对内置行施加的 patch 原地修改那一行,所以即使该组合包自己的行失败,效果仍然保留;这是组合包唯一能在自己的组之外造成影响的地方。 +- **id 前缀不改写 `!!js` 字面量**——组合包的 disabled 表达式若拿 `e.options.id` 与自己未加前缀的 id 比较,字面量保持原样;请改为比较 `options.name`。 +- **嵌套 fiber 审计只是提示**——内置条目下失败的 `ctx.inject()` 延续会被报告而非致命,直到确认随附组合都没有这类失败。 ### 开发备注 diff --git a/packages/boot/app-boot/src/contained-group.ts b/packages/boot/app-boot/src/contained-group.ts new file mode 100644 index 0000000000..19c4d5d53c --- /dev/null +++ b/packages/boot/app-boot/src/contained-group.ts @@ -0,0 +1,168 @@ +/** + * The contained group: a Loader group whose rows fail individually instead of + * failing the group. The vendored `EntryGroup.update` is all-or-nothing — one + * rejected row rolls the whole group back and rethrows, which at boot unwinds + * the whole tree. External bundles mount under this group so their startup + * failures are recorded and reported rather than fatal, while built-in rows + * keep the fatal path. + * @module @deepseek-ai/dsh-app-boot/contained-group + */ + +import type { Context } from '@deepseek-ai/cordis' +import { Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' + +/** The lifecycle step at which a contained row failed. */ +export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'unknown' + +/** One recorded failure of a row inside a contained group. */ +export interface ContainedFailure { + /** The row's tree-wide id, as `Entry.id` spells it (`include:` under the root include). */ + readonly entryId: string + /** The row id as the composition declares it — the key patches and provenance use. */ + readonly rowId: string + /** The module specifier the row named. */ + readonly moduleName: string + /** The contained group the row belongs to. */ + readonly groupId: string + /** Which lifecycle step failed. */ + readonly stage: ContainedFailureStage + /** The failure text, with the Loader's per-row wrapper folded in. */ + readonly message: string +} + +/** + * Failures recorded by contained groups of one runtime. Rows are keyed by + * their tree-wide id; recording a row again replaces its earlier record, and a + * row that later mounts clears it. + */ +export class ContainedFailureRegistry { + private readonly failures = new Map() + + /** + * Record one failure, replacing an earlier record of the same row. + * @param failure - the failure to record. + */ + record(failure: ContainedFailure): void { + this.failures.set(failure.entryId, failure) + } + + /** + * Forget one row's failure, for a row that mounted on a later attempt. + * @param entryId - the row's tree-wide id. + */ + clear(entryId: string): void { + this.failures.delete(entryId) + } + + /** + * Every recorded failure in record order. + * @returns the failures. + */ + list(): ContainedFailure[] { + return [...this.failures.values()] + } + + /** + * One row's failure. + * @param entryId - the row's tree-wide id. + * @returns the record, or undefined when the row is not failed. + */ + get(entryId: string): ContainedFailure | undefined { + return this.failures.get(entryId) + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Failures recorded by contained groups; provided on the root by the boot glue. */ + pluginFailures?: ContainedFailureRegistry + } +} + +/** + * Parse the Loader's `failed to loader entry` wrapper into a stage. + * Every row failure reaches {@link ContainedGroup.create} through that + * wrapper, so `unknown` is the guard for a message shape this build has not + * seen rather than a path a row can take. + */ +function stageOf(message: string): ContainedFailureStage { + const match = /^failed to (import|apply) loader entry /.exec(message) + /* v8 ignore next -- the Loader wraps every row failure with one of the two stages, so the `unknown` arm is unreachable */ + return match === null ? 'unknown' : match[1] === 'import' ? 'import' : 'apply' +} + +/** + * A group that contains its rows' startup failures. `create()` is the one + * per-row step `EntryGroup.update` awaits, so catching there is what turns a + * row failure from a group rejection into a record: the group activates, the + * failed row is absent from the tree, and the record names it. + */ +export class ContainedGroup extends Group { + override async create(options: Omit): Promise { + try { + const id = await super.create(options) + this.registry()?.clear(id) + return id + } catch (error) { + const registry = this.registry() + // `ensureId` assigned the id before the row started, so a failed row + // still carries the id the tree would have used. + const rowId = (options as EntryOptions).id + // The same spelling `Entry.id` gives a live row: prefixed with the + // owning include's id when this tree is an included one. + const treeOwner = this.tree.ctx.fiber.entry + const entryId = treeOwner === undefined ? rowId : `${treeOwner.id}:${rowId}` + /* v8 ignore next -- the Loader wraps a row's thrown value in an Error before it reaches here */ + const message = error instanceof Error ? error.message : String(error) + registry?.record({ + entryId, + rowId, + moduleName: options.name, + groupId: this.groupId(), + stage: stageOf(message), + message, + }) + this.ctx.logger.warn(`contained group ${this.groupId()}: row ${rowId} failed and was isolated: ${message}`) + return rowId + } + } + + /** The registry provided on the runtime root, if the boot glue provided one. */ + private registry(): ContainedFailureRegistry | undefined { + return this.ctx.get('pluginFailures') + } + + /** This group's own entry id, as the tree names it. */ + private groupId(): string { + /* v8 ignore next -- a group always runs as a Loader entry; the fallback keeps a direct `ctx.plugin(ContainedGroup)` readable */ + return this.ctx.fiber.entry?.id ?? '(unknown group)' + } +} + +/** + * Whether an entry sits inside a contained group, at any depth. + * @param entry - the Loader entry to classify. + * @returns true when some owning group is a {@link ContainedGroup}. + */ +export function isContainedEntry(entry: Entry): boolean { + let group: EntryGroup | undefined = entry.parent + while (group !== undefined) { + if (group instanceof ContainedGroup) return true + group = group.ctx.fiber.entry?.parent + } + return false +} + +/** + * The contained failure registry of a runtime, creating and providing one on + * the root the first time it is asked for. + * @param ctx - any context of the runtime. + * @returns the registry. + */ +export function ensurePluginFailures(ctx: Context): ContainedFailureRegistry { + const existing = ctx.get('pluginFailures') + if (existing !== undefined) return existing + const registry = new ContainedFailureRegistry() + ctx.root.provide('pluginFailures', registry) + return registry +} diff --git a/packages/boot/app-boot/src/external-bundles.ts b/packages/boot/app-boot/src/external-bundles.ts new file mode 100644 index 0000000000..d7eb73b98d --- /dev/null +++ b/packages/boot/app-boot/src/external-bundles.ts @@ -0,0 +1,278 @@ +/** + * External bundle composition and the profile-manifest operations behind + * installing, enabling, and disabling bundles. + * + * An external (`runtime` stage) bundle never mounts its rows directly into the + * built-in tree. Its inserted rows are wrapped in one contained group per + * bundle, each row id is prefixed with the package name, and the bundle's own + * patches that address those rows are rewritten to the prefixed ids. Two + * facts of the vendored Loader make this necessary: entry ids are unique per + * tree (`tree.store`), so two bundles inserting the same id would otherwise + * make the second silently take over the first's entry; and a group is the + * unit the Loader rolls back, so one group per bundle is what makes a bundle + * fail as a whole rather than half-mount. + * @module @deepseek-ai/dsh-app-boot/external-bundles + */ + +import { join } from 'node:path' +import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' + +/** + * Whether a `disabled` node is a `!!js` expression rather than a literal. + * @param value - the raw `disabled` node of a row or patch. + * @returns true for an expression node. + */ +export function isJsDisabled(value: unknown): boolean { + return isJsExpr(value) +} +import { + readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest, +} from './profile.ts' + +/** Loader builtin name of the contained group every `runtime` bundle mounts under. */ +export const CONTAINED_GROUP_MODULE = 'cordis:contained-group' + +/** Prefix of the group id one external bundle's rows mount under. */ +export const BUNDLE_GROUP_PREFIX = 'bundle/' + +/** + * The group id one external bundle's rows mount under. `/` rather than `:` + * because `:` is the Loader's nested-id separator (`EntryTree.sep`), and an + * id containing it would not resolve through `loader.update(id, …)`. + * @param packageName - the bundle's package name. + * @returns the group entry id. + */ +export function bundleGroupId(packageName: string): string { + return `${BUNDLE_GROUP_PREFIX}${packageName}` +} + +/** + * The tree-wide id of one row an external bundle inserted. + * @param packageName - the bundle's package name. + * @param rowId - the id the bundle's own patch declares. + * @returns the prefixed id the mounted tree uses. + */ +export function externalRowId(packageName: string, rowId: string): string { + return `${packageName}/${rowId}` +} + +/** Where one mounted row came from, keyed by its tree-wide id. */ +export interface ExternalRowOrigin { + /** The bundle that inserted the row. */ + packageName: string + /** The id the bundle's own patch declared, before prefixing. */ + originalId: string +} + +/** One external layer rendered as the patches the tree mounts. */ +export interface ComposedExternalLayer { + /** Patches in application order: the group insert first, then the bundle's rewritten patches. */ + patches: PatchOptions[] + /** Every row the bundle inserted, by prefixed id. */ + rows: Map + /** Built-in row ids the bundle overrides; not containable, reported for visibility. */ + overrides: string[] +} + +/** Deep-clone one entry row and prefix its id and, for a group, its children's ids. */ +function prefixRow(packageName: string, row: EntryOptions, rows: Map, ownIds: Set): EntryOptions { + const cloned = structuredClone(row) + if (typeof cloned.id === 'string') { + ownIds.add(cloned.id) + const prefixed = externalRowId(packageName, cloned.id) + rows.set(prefixed, { packageName, originalId: cloned.id }) + cloned.id = prefixed + } + if (cloned.group && Array.isArray(cloned.config)) { + cloned.config = (cloned.config as EntryOptions[]).map(child => prefixRow(packageName, child, rows, ownIds)) + } + return cloned +} + +/** + * Render one external bundle layer as contained, prefixed patches. Root + * inserts become the children of the bundle's group; inserts into a named + * group the bundle itself introduced follow the prefixed id; inserts into a + * built-in group are nested in their own contained group inside that target; + * an id-targeted patch is rewritten when it addresses a row this bundle + * inserted and passed through unchanged when it addresses a built-in row. + * @param layer - the resolved external layer. + * @returns the patches to mount and the provenance of every inserted row. + */ +export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer { + const { packageName } = layer + const rows = new Map() + const ownIds = new Set() + const groupRows: EntryOptions[] = [] + const trailing: PatchOptions[] = [] + const overrides: string[] = [] + // First pass: inserts, so the ids this bundle introduces are known before + // its id-targeted patches are classified. + for (const patch of layer.patches) { + if (patch.insert === undefined) continue + const inserted = patch.insert.map(row => prefixRow(packageName, row, rows, ownIds)) + if (patch.id === undefined) { + groupRows.push(...inserted) + continue + } + if (ownIds.has(patch.id)) { + trailing.push({ id: externalRowId(packageName, patch.id), insert: inserted }) + continue + } + const nestedId = `${bundleGroupId(packageName)}/in/${patch.id}` + rows.set(nestedId, { packageName, originalId: nestedId }) + trailing.push({ + id: patch.id, + insert: [{ id: nestedId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }], + }) + } + for (const patch of layer.patches) { + if (patch.insert !== undefined || patch.id === undefined) continue + if (ownIds.has(patch.id)) { + trailing.push({ ...structuredClone(patch), id: externalRowId(packageName, patch.id) }) + continue + } + overrides.push(patch.id) + trailing.push(structuredClone(patch)) + } + const groupId = bundleGroupId(packageName) + rows.set(groupId, { packageName, originalId: groupId }) + const group: EntryOptions = { id: groupId, name: CONTAINED_GROUP_MODULE, group: true, config: groupRows } + return { patches: [{ insert: [group] }, ...trailing], rows, overrides } +} + +/** + * Whether an installed dependency exports a profile patch, i.e. is a bundle. + * @param binName - the diagnostic prefix used by manifest reads. + * @param packageName - the dependency's package name. + * @param installAnchor - absolute path of the dsh app's package.json. + * @param profileDir - the profile directory (second resolution anchor). + * @returns true when the package manifest declares `dsh.bundle`. + */ +export function exportsBundlePatch( + binName: string, packageName: string, installAnchor: string, profileDir: string, +): boolean { + let dir: string + try { + dir = resolveBundleDir(binName, packageName, installAnchor, profileDir) + } catch { + return false // pnpm reported success yet the package is unresolvable — treat as plain + } + return readProfileManifest(binName, dir).dsh?.bundle?.patch !== undefined +} + +/** Outcome of one reconciliation of the bundle layer list against installed state. */ +export interface BundleReconciliation { + /** Bundles newly enabled (appended to `dsh.profile.bundles`). */ + enabled: string[] + /** Bundles removed from the layer list because their dependency is gone or no longer a bundle. */ + removed: string[] + /** Newly added dependencies that declare no `dsh.bundle` (plain libraries or plugin modules). */ + plain: string[] + /** Installed bundles left out of the layer list because `autoEnable` was off. */ + installedOnly: string[] +} + +/** + * Reconcile `dsh.profile.bundles` against the installed state after a pnpm + * run. A dependency that no longer resolves to a bundle leaves the layer + * list; template bundles (never dependencies) are untouched. A dependency + * that resolves to a bundle joins the list only when `autoEnable` is set — + * the CLI's install-and-enable semantics — and is otherwise reported as + * installed-only, which is the plugin manager's install step. + * @param binName - the diagnostic prefix used by manifest reads. + * @param profileDir - the profile directory. + * @param installAnchor - absolute path of the dsh app's package.json. + * @param before - the manifest as read before the pnpm run. + * @param options - whether newly installed bundles join the layer list. + * @returns what changed, after the manifest was written when anything did. + */ +export function reconcileInstalledBundles( + binName: string, + profileDir: string, + installAnchor: string, + before: ProfileManifest, + options: { autoEnable: boolean }, +): BundleReconciliation { + const after = readProfileManifest(binName, profileDir) + const beforeDeps = new Set(Object.keys(before.dependencies ?? {})) + const dependencies = Object.keys(after.dependencies ?? {}) + const bundles = [...after.dsh?.profile?.bundles ?? []] + const outcome: BundleReconciliation = { enabled: [], removed: [], plain: [], installedOnly: [] } + for (const packageName of dependencies) { + const isBundle = exportsBundlePatch(binName, packageName, installAnchor, profileDir) + if (isBundle && !bundles.includes(packageName)) { + if (options.autoEnable) { + bundles.push(packageName) + outcome.enabled.push(packageName) + } else { + outcome.installedOnly.push(packageName) + } + } else if (!isBundle && !beforeDeps.has(packageName)) { + outcome.plain.push(packageName) + } + } + const dependencySet = new Set(dependencies) + for (const packageName of [...bundles]) { + // Only dependency-managed entries are subject to removal; template + // bundles are not dependencies. + const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName) + const stillBundle = dependencySet.has(packageName) && exportsBundlePatch(binName, packageName, installAnchor, profileDir) + if (wasDependency && !stillBundle) { + bundles.splice(bundles.indexOf(packageName), 1) + outcome.removed.push(packageName) + } + } + if (outcome.enabled.length > 0 || outcome.removed.length > 0) { + writeProfileManifest(profileDir, withBundles(after, bundles)) + } + return outcome +} + +/** The manifest with its bundle layer list replaced, every other field kept. */ +function withBundles(manifest: ProfileManifest, bundles: string[]): ProfileManifest { + return { ...manifest, dsh: { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } } } +} + +/** + * Add one installed bundle to the profile's layer list. + * @param binName - the diagnostic prefix on thrown errors. + * @param profileDir - the profile directory. + * @param installAnchor - absolute path of the dsh app's package.json. + * @param packageName - the installed dependency to enable. + * @returns true when the list changed; false when the bundle was already enabled. + * @throws when the package is not an installed dependency or declares no `dsh.bundle`. + */ +export function enableBundle(binName: string, profileDir: string, installAnchor: string, packageName: string): boolean { + const manifest = readProfileManifest(binName, profileDir) + if (!(packageName in (manifest.dependencies ?? {}))) { + throw new Error(`${binName}: ${packageName} is not installed in profile ${join(profileDir, 'package.json')}`) + } + if (!exportsBundlePatch(binName, packageName, installAnchor, profileDir)) { + throw new Error(`${binName}: ${packageName} declares no dsh.bundle; add its plugin modules to a composition instead of enabling it`) + } + const bundles = manifest.dsh?.profile?.bundles ?? [] + if (bundles.includes(packageName)) return false + writeProfileManifest(profileDir, withBundles(manifest, [...bundles, packageName])) + return true +} + +/** + * Remove one dependency-managed bundle from the profile's layer list. + * @param binName - the diagnostic prefix on thrown errors. + * @param profileDir - the profile directory. + * @param packageName - the bundle to disable. + * @returns true when the list changed; false when the bundle was not enabled. + * @throws when the bundle is a template bundle, which is not a dependency and cannot be disabled. + */ +export function disableBundle(binName: string, profileDir: string, packageName: string): boolean { + const manifest = readProfileManifest(binName, profileDir) + const bundles = manifest.dsh?.profile?.bundles ?? [] + if (!bundles.includes(packageName)) return false + if (!(packageName in (manifest.dependencies ?? {}))) { + throw new Error(`${binName}: ${packageName} is a template bundle of this profile and cannot be disabled`) + } + writeProfileManifest(profileDir, withBundles(manifest, bundles.filter(name => name !== packageName))) + return true +} diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 8059b4849c..4b61d902e7 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -19,6 +19,7 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/cordis-plugin-hmr' import type {} from '@deepseek-ai/dsh-system-prompt' +import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -41,6 +42,8 @@ export { resolveBundleDir, resolveProfileDir, writeProfileManifest, + type BundleStage, + type BundleTrust, type DshBundleManifest, type DshManifestSection, type DshProfileManifest, @@ -51,6 +54,22 @@ export { type ProfilePatchReload, type ProfileTemplate, } from './profile.ts' +export { + ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry, + type ContainedFailure, type ContainedFailureStage, +} from './contained-group.ts' +export { + BUNDLE_GROUP_PREFIX, bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle, + exportsBundlePatch, externalRowId, isJsDisabled, reconcileInstalledBundles, + type BundleReconciliation, type ComposedExternalLayer, type ExternalRowOrigin, +} from './external-bundles.ts' +export { + ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin, +} from './profile-runtime.ts' +export { + PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, + type PluginProbe, type PluginProbeRow, type ProbeOptions, +} from './probe.ts' /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for @@ -505,6 +524,15 @@ function groupedDump( return lines.join('\n') + '\n' } +/** + * The root Include entry {@link mountRootInclude} created for a context. + * @param ctx - the booted root context. + * @returns the entry, or undefined before the root include mounted. + */ +export function rootIncludeEntry(ctx: Context): Entry | undefined { + return bootstrapIncludes.get(ctx) +} + /** * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR. * @param ctx - context carrying an initialized Loader service. @@ -541,6 +569,10 @@ export async function mountRootInclude( // by name. Both builtins load through the ambient module pipeline, so neither // depends on the included tree's own specifier resolution. ctx.loader.builtins.group = Group + // `cordis:contained-group`: the group external bundles mount under, whose + // rows fail individually and are recorded on the root's failure registry. + ctx.loader.builtins['contained-group'] = ContainedGroup + ensurePluginFailures(ctx) // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). @@ -681,6 +713,45 @@ export function installFailLoud( return uninstall } +/** The slice of `process` {@link installRuntimeGuards} needs. */ +export interface RuntimeGuardProcess { + on(event: 'unhandledRejection' | 'uncaughtException', handler: (err: unknown) => void): unknown + off(event: 'unhandledRejection' | 'uncaughtException', handler: (err: unknown) => void): unknown + exit(code: number): unknown +} + +/** + * The post-boot replacement for {@link installFailLoud}. Once the tree is up, + * an unhandled rejection is no longer a load failure: it is most likely a + * plugin's stray continuation, and exiting would take every session down for + * it. The rejection is reported and the process keeps running. An uncaught + * exception leaves the process in an unknown state, so it is reported and + * the process exits, as Node would — but with the origin named. + * @param binName - the diagnostic prefix on each report. + * @param report - sink for the report lines. + * @param proc - the process slice to register on; defaults to `process`. + * @returns a disposer removing both handlers. + */ +export function installRuntimeGuards( + binName: string, + report: (line: string) => void, + proc: RuntimeGuardProcess = process, +): () => void { + const onRejection = (err: unknown): void => { + report(`${binName}: unhandled rejection after boot (contained; the process keeps running): ${formatActivationError(err)}`) + } + const onException = (err: unknown): void => { + report(`${binName}: uncaught exception after boot; exiting: ${formatActivationError(err)}`) + proc.exit(1) + } + proc.on('unhandledRejection', onRejection) + proc.on('uncaughtException', onException) + return () => { + proc.off('unhandledRejection', onRejection) + proc.off('uncaughtException', onException) + } +} + /** * After the tree settles, reject entries with no fiber and name every plugin * whose module failed to resolve. Disabled entries are the only valid @@ -689,7 +760,8 @@ export function installFailLoud( * @param binName - the diagnostic prefix on the thrown error. */ export function assertEntriesLoaded(ctx: Context, binName: string): void { - const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) + const failed = [...ctx.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled && !isContainedEntry(entry)) if (failed.length > 0) { const names = failed.map(entry => entry.options.name).join(', ') throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`) @@ -710,6 +782,12 @@ function formatActivationError(error: unknown): string { return error instanceof Error ? error.stack ?? error.message : String(error) } +/** The tree-wide id of the group entry that owns a contained row. */ +function owningGroupId(entry: Entry): string { + /* v8 ignore next -- a contained entry is by definition inside a group entry; the fallback keeps the type total */ + return entry.parent.ctx.fiber.entry?.id ?? '' +} + /** * Reject a settled Loader tree when an enabled entry failed or remains inactive. * Plugin failures include the original thrown stack; pending entries name their @@ -726,15 +804,28 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro assertEntriesLoaded(ctx, binName) const failures: string[] = [] const rejectionReasons: unknown[] = [] + const registry = ctx.get('pluginFailures') + let builtinPending = false for (const entry of ctx.loader.entries()) { const fiber = entry.fiber if (fiber === undefined || entry.disabled) continue const state = fiber.state - if (state === FIBER_ACTIVE) continue + if (state === FIBER_ACTIVE) { + registry?.clear(entry.id) + continue + } + const contained = isContainedEntry(entry) if (state === FIBER_FAILED) { try { await fiber.await() } catch (error) { + if (contained) { + registry?.record({ + entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name, + groupId: owningGroupId(entry), stage: 'apply', message: formatActivationError(error), + }) + continue + } rejectionReasons.push(error) failures.push(`${entry.options.name}: ${formatActivationError(error)}`) } @@ -743,7 +834,16 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro if (state === FIBER_PENDING) { const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) const subject = missing.length === 1 ? 'service' : 'services' - failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`) + const line = `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})` + if (contained) { + registry?.record({ + entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name, + groupId: owningGroupId(entry), stage: 'inject-pending', message: line, + }) + continue + } + builtinPending = true + failures.push(`${entry.options.name}: ${line}`) } else { failures.push(`${entry.options.name}: fiber state ${String(state)}`) } @@ -753,10 +853,50 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro await observeLoaderRejectionCheckpoint(rejectionReasons) } const noun = failures.length === 1 ? 'entry' : 'entries' - throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`) + // A contained failure is only harmless while nothing built-in depends on + // it: a built-in row left waiting for a service names the isolated + // bundles, because one of them is the likely missing provider. + const isolated = builtinPending ? registry?.list() ?? [] : [] + const hint = isolated.length === 0 + ? '' + : `\n${binName}: isolated bundle failure(s) may be the missing provider — ` + + `check ${[...new Set(isolated.map(failure => failure.groupId))].join(', ')} ` + + 'or mark that bundle `dsh.bundle.stage: boot` so it fails loud' + throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}${hint}`) } } +/** + * Nested plugin fibers (a `ctx.inject()` continuation inside a plugin) fail + * without touching their entry's root fiber, so the activation audit above + * cannot see them. Report every failed nested fiber under a built-in entry + * through `warn`, one line per fiber. Advisory in this release; it becomes + * part of the fatal audit once shipped compositions are known clean. + * @param ctx - the settled context whose runtimes to inspect. + * @param binName - the diagnostic prefix on each warning. + * @param warn - sink for the warning lines; defaults to the context logger. + * @returns the number of failed nested fibers reported. + */ +export function warnNestedFiberFailures( + ctx: Context, + binName: string, + warn: (line: string) => void = (line) => { ctx.logger.warn(line) }, +): number { + let count = 0 + for (const runtime of ctx.registry.values()) { + for (const fiber of runtime.fibers) { + // The Loader stamps every fiber created inside an entry's context with + // that entry; the entry's own root fiber is the one the entry holds. + const owner = fiber.entry + if (fiber.state !== FIBER_FAILED || owner === undefined || owner.fiber === fiber) continue + if (isContainedEntry(owner)) continue + count += 1 + warn(`${binName}: nested fiber under ${owner.options.name} failed (${runtime.name}); the entry itself stays active`) + } + } + return count +} + /** * Boot the Loader against `absoluteConfigPath` and return only after the whole * tree settles. Relative entry names resolve against the config directory; diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts new file mode 100644 index 0000000000..e4adeed8d6 --- /dev/null +++ b/packages/boot/app-boot/src/probe.ts @@ -0,0 +1,312 @@ +/** + * The install-time probe: what an installed package is and whether this + * harness can load it, answered in a child process so a package that throws, + * hangs, or brings its own copy of cordis never runs inside the host. The + * manifest facts (kind, rows, declared modules) are read here; the child only + * imports. + * @module @deepseek-ai/dsh-app-boot/probe + */ + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { loadOverlayPatches } from './index.ts' +import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts' + +/** Directory under a profile holding one probe record per package. */ +export const PLUGIN_PROBE_DIR = '.dsh-plugins' + +/** One row a bundle's patch inserts, as its own patch declares it. */ +export interface PluginProbeRow { + /** The row id the bundle declares, or undefined when the row leaves it to the Loader. */ + readonly id?: string + /** The module specifier the row names. */ + readonly name: string + /** Whether the row carries a `disabled` gate. */ + readonly gated: boolean +} + +/** One agent-plane module a package declares addable to a composition. */ +export interface PluginProbeAddable { + /** The subpath or bare specifier, as declared in `dsh.plugins[].name`. */ + readonly name: string + /** Display title, when declared. */ + readonly title?: string + /** Default row config, when declared. */ + readonly config?: unknown + /** Whether the child process imported the module and found a plugin. */ + readonly ok: boolean + /** The import failure, when `ok` is false. */ + readonly error?: string + /** The module's `Config` schema envelope, when it declares one. */ + readonly configSchema?: unknown +} + +/** What the probe learned about one installed package. */ +export interface PluginProbe { + /** The package name. */ + readonly packageName: string + /** The package version as installed. */ + readonly version?: string + /** Free text from the manifest. */ + readonly description?: string + /** Display title from `dsh.title`, when declared. */ + readonly title?: string + /** `bundle` (declares `dsh.bundle`), `plugin` (its main export is a cordis plugin), else `library`. */ + readonly kind: 'bundle' | 'plugin' | 'library' + /** Whether the package can be enabled or added: it imported and shares the harness's cordis. */ + readonly ok: boolean + /** Why it cannot, when `ok` is false. */ + readonly reason?: string + /** Whether the package resolves `@deepseek-ai/cordis` to the harness's own copy; null when unknown. */ + readonly cordisSameCopy: boolean | null + /** The harness version range the package declares in `engines.dsh`. */ + readonly enginesDsh?: string + /** Rows the bundle's patch inserts (unprefixed), empty for a non-bundle. */ + readonly rows: readonly PluginProbeRow[] + /** Ids of rows outside the bundle that its patch overrides. */ + readonly overrides: readonly string[] + /** Agent-plane modules the package declares addable. */ + readonly addable: readonly PluginProbeAddable[] + /** The main export's `Config` schema envelope, for a `plugin` kind. */ + readonly configSchema?: unknown + /** ISO time of the probe. */ + readonly checkedAt: string +} + +/** Inputs of {@link probePackage}. */ +export interface ProbeOptions { + /** The diagnostic prefix on thrown errors. */ + binName: string + /** The profile directory the package is installed in. */ + profileDir: string + /** Absolute path of the dsh app's package.json (first resolution anchor). */ + installAnchor: string + /** The package to probe. */ + packageName: string + /** Bound on the child process; default 20 seconds. */ + timeoutMs?: number + /** The Node executable; defaults to the running one. */ + nodeExecutable?: string +} + +/** The manifest slice the probe reads beyond the profile launcher's. */ +interface ProbedManifest extends ProfileManifest { + main?: string + exports?: unknown + engines?: Record + dsh?: ProfileManifest['dsh'] & { + title?: string + plugins?: { name: string; title?: string; config?: unknown }[] + } +} + +/** What the child process reports. */ +interface ChildReport { + cordis: string | null + main: { ok: boolean; isPlugin: boolean; configSchema: unknown; error?: string } + addable: Record +} + +/** + * The script the child runs: resolve cordis from the package, import the main + * export and every declared addable module, and report. Parameters arrive + * as argv so no value is interpolated into code. + */ +const CHILD_SCRIPT = ` +import { pathToFileURL } from 'node:url' +const [dir, mainSpecifier, addableJson] = process.argv.slice(1) +const base = pathToFileURL(dir + '/package.json').href +const report = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} } +try { report.cordis = import.meta.resolve('@deepseek-ai/cordis', base) } catch { report.cordis = null } +const inspect = async (specifier) => { + try { + const mod = await import(import.meta.resolve(specifier, base)) + const plugin = mod.default ?? mod + const schema = plugin?.Config ?? mod.Config + return { + ok: true, + isPlugin: typeof plugin === 'function' || typeof plugin?.apply === 'function', + configSchema: typeof schema?.toJSON === 'function' ? schema.toJSON() : null, + } + } catch (error) { + return { ok: false, isPlugin: false, configSchema: null, error: String(error?.stack ?? error) } + } +} +if (mainSpecifier !== '') report.main = await inspect(mainSpecifier) +for (const name of JSON.parse(addableJson)) report.addable[name] = await inspect(name) +process.stdout.write(JSON.stringify(report)) +` + +/** Run the child and parse its report. */ +function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + return new Promise((resolve, reject) => { + const child = spawn( + options.nodeExecutable ?? process.execPath, + ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)], + { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } }, + ) + const out: Buffer[] = [] + const err: Buffer[] = [] + child.stdout.on('data', (chunk: Buffer) => out.push(chunk)) + child.stderr.on('data', (chunk: Buffer) => err.push(chunk)) + // One settlement: a spawn failure emits `error` and then `close`, and a + // timeout kill emits `close` after the rejection below. + let settled = false + const settle = (outcome: () => void): void => { + if (settled) return + settled = true + clearTimeout(timer) + outcome() + } + const timer = setTimeout(() => { + child.kill('SIGKILL') + settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) }) + }, timeoutMs) + child.on('error', (error) => { settle(() => { reject(error) }) }) + child.on('close', (code) => { + settle(() => { + const stdout = Buffer.concat(out).toString('utf8') + try { + resolve(JSON.parse(stdout) as ChildReport) + } catch { + reject(new Error( + `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`, + )) + } + }) + }) + }) +} + +const DEFAULT_TIMEOUT_MS = 20_000 + +/** The rows a bundle patch inserts, flattened from nested groups, with the ids of overrides on other rows. */ +function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } { + const rows: PluginProbeRow[] = [] + const own = new Set() + const visit = (row: EntryOptions): void => { + if (typeof row.id === 'string') own.add(row.id) + rows.push({ ...typeof row.id === 'string' ? { id: row.id } : {}, name: row.name, gated: row.disabled !== undefined }) + if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit) + } + const patches = loadOverlayPatches(binName, patchPath) + for (const patch of patches) patch.insert?.forEach(visit) + const overrides = patches + .filter(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id)) + .map(patch => patch.id as string) + return { rows, overrides } +} + +/** + * Probe one installed package: read its manifest, then import it in a child + * process. The child never runs inside the host, so a package that throws at + * import, exits, or hangs costs one child process and yields a `ok: false` + * record with the reason. + * @param options - where the package lives and how to run the child. + * @returns the probe record. + * @throws when the package is not resolvable from the profile, or the child produced no report. + */ +export async function probePackage(options: ProbeOptions): Promise { + const packageDir = resolveBundleDir(options.binName, options.packageName, options.installAnchor, options.profileDir) + const manifest = readProfileManifest(options.binName, packageDir) as ProbedManifest + const declaredBundle = manifest.dsh?.bundle?.patch + const declaredAddable = manifest.dsh?.plugins ?? [] + const { rows, overrides } = declaredBundle === undefined + ? { rows: [], overrides: [] } + : describeBundlePatch(options.binName, join(packageDir, declaredBundle)) + const hasMain = manifest.main !== undefined || manifest.exports !== undefined + const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name)) + const harnessCordis = resolveHarnessCordis() + const cordisSameCopy = report.cordis === null || harnessCordis === undefined ? null : report.cordis === harnessCordis + const kind: PluginProbe['kind'] = declaredBundle !== undefined ? 'bundle' : report.main.isPlugin ? 'plugin' : 'library' + const addable: PluginProbeAddable[] = declaredAddable.map((entry) => { + // The child reports every declared name; a missing report is a child + // that did not run the loop, which the report parse already rejected. + /* v8 ignore next */ + const inspected = report.addable[entry.name] ?? { ok: false, isPlugin: false, configSchema: null, error: 'no report' } + return { + name: entry.name, + ...entry.title === undefined ? {} : { title: entry.title }, + ...entry.config === undefined ? {} : { config: entry.config }, + ok: inspected.ok && inspected.isPlugin, + ...inspected.error === undefined ? {} : { error: inspected.error }, + ...inspected.configSchema === null ? {} : { configSchema: inspected.configSchema }, + } + }) + let reason: string | undefined + // The child attaches the thrown value to every failed import; the fallback keeps the type total. + /* v8 ignore next */ + if (hasMain && !report.main.ok) reason = `the package failed to import: ${report.main.error ?? 'unknown error'}` + else if (cordisSameCopy === false) reason = 'the package resolves its own copy of @deepseek-ai/cordis instead of the harness\'s; declare cordis as a peer dependency' + return { + packageName: options.packageName, + ...manifest.version === undefined ? {} : { version: manifest.version }, + ...manifest.description === undefined ? {} : { description: manifest.description }, + ...manifest.dsh?.title === undefined ? {} : { title: manifest.dsh.title }, + kind, + ok: reason === undefined, + ...reason === undefined ? {} : { reason }, + cordisSameCopy, + ...manifest.engines?.dsh === undefined ? {} : { enginesDsh: manifest.engines.dsh }, + rows, + overrides, + addable, + ...report.main.configSchema === null || report.main.configSchema === undefined ? {} : { configSchema: report.main.configSchema }, + checkedAt: new Date().toISOString(), + } +} + +/** + * The copy of `@deepseek-ai/cordis` this harness runs: the one this module + * resolves, which is the one every built-in plugin shares. Undefined only in + * an environment that cannot resolve it at all. + */ +function resolveHarnessCordis(): string | undefined { + try { + return import.meta.resolve('@deepseek-ai/cordis') + } catch { + // Only an embedder without cordis on its module path lands here; the + // harness itself always resolves its own peer. + /* v8 ignore next */ + return undefined + } +} + +/** The cache file for one package under a profile. */ +function probeCachePath(profileDir: string, packageName: string): string { + return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`) +} + +/** + * Read one package's cached probe record. + * @param profileDir - the profile directory. + * @param packageName - the package. + * @param version - when given, a record for a different version is treated as absent. + * @returns the record, or undefined when none is cached. + */ +export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined { + const path = probeCachePath(profileDir, packageName) + if (!existsSync(path)) return undefined + let record: PluginProbe + try { + record = JSON.parse(readFileSync(path, 'utf8')) as PluginProbe + } catch { + return undefined + } + if (version !== undefined && record.version !== version) return undefined + return record +} + +/** + * Persist one package's probe record under the profile. + * @param profileDir - the profile directory. + * @param probe - the record to persist. + */ +export function writeProbeCache(profileDir: string, probe: PluginProbe): void { + const path = probeCachePath(profileDir, probe.packageName) + mkdirSync(join(profileDir, PLUGIN_PROBE_DIR), { recursive: true }) + writeFileSync(path, JSON.stringify(probe, undefined, 2) + '\n') +} diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts new file mode 100644 index 0000000000..6bbd2c0cda --- /dev/null +++ b/packages/boot/app-boot/src/profile-runtime.ts @@ -0,0 +1,167 @@ +/** + * The `profileRuntime` service: the booted profile's facts and the one + * recomposition entry point every live change to the host tree goes through — + * user patch-file reloads, bundle enable/disable, and hot install. Before + * this service the composition closure lived in the launcher and bundle + * layers were frozen at boot, so nothing in the tree could learn which + * profile it ran in or add a layer while running. + * @module @deepseek-ai/dsh-app-boot/profile-runtime + */ + +import { Context, Service } from '@deepseek-ai/cordis' +import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type Include from '@deepseek-ai/cordis-plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { composeExternalLayer, isJsDisabled } from './external-bundles.ts' +import type { BundleTrust, Profile, ProfileLayer, ProfilePatchReload } from './profile.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The booted profile's facts and recomposition; provided by the profile launcher. */ + profileRuntime: ProfileRuntime + } +} + +/** Where one mounted row came from. */ +export interface RowOrigin { + /** Who supplied the layer that inserted the row. */ + readonly trust: BundleTrust + /** The bundle package that inserted the row. */ + readonly packageName: string + /** The package's version, when its manifest declares one. */ + readonly version?: string + /** For an external row: the id the bundle's own patch declared, before prefixing. */ + readonly originalId?: string +} + +/** What the launcher hands the service. */ +export interface ProfileRuntimeOptions { + /** The profile as booted. */ + profile: Profile + /** Re-read the profile from disk, re-resolving its bundle layers. */ + loadProfile: () => Profile + /** The complete patch stack for a profile: bundle layers, user layers, overlays. */ + compose: (profile: Profile) => PatchOptions[] + /** The root Include entry, once mounted. */ + rootEntry: () => Entry | undefined + /** The user patch layers as they stand on disk (profile file, then home file). */ + readUserPatches: () => PatchOptions[] +} + +/** Collect every id a patch list inserts, recursing into inserted groups. */ +function insertedIds(patches: readonly PatchOptions[], into: Set): void { + const visit = (row: EntryOptions): void => { + if (typeof row.id === 'string') into.add(row.id) + if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit) + } + for (const patch of patches) patch.insert?.forEach(visit) +} + +/** Facts and recomposition of the booted profile. */ +export class ProfileRuntime extends Service { + private profile: Profile + private origins: Map | undefined + + constructor(ctx: Context, private readonly options: ProfileRuntimeOptions) { + super(ctx, 'profileRuntime') + this.profile = options.profile + } + + /** The profile as currently composed; re-read by a `recompose({ reloadBundles: true })`. */ + get current(): Profile { + return this.profile + } + + /** The profile name (`dsh --profile `). */ + get profileName(): string { + return this.profile.name + } + + /** Absolute profile directory. */ + get dir(): string { + return this.profile.dir + } + + /** Absolute path of the profile's own user patch file. */ + get patchPath(): string { + return this.profile.patchPath + } + + /** Whether user patch files reload while the profile runs. */ + get patchReload(): ProfilePatchReload { + return this.profile.patchReload + } + + /** The bundle layers currently composed, in application order. */ + get layers(): readonly ProfileLayer[] { + return this.profile.layers + } + + /** + * Where one mounted row came from. + * @param rowId - the row's tree-wide id. + * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row). + */ + originOf(rowId: string): RowOrigin | undefined { + this.origins ??= this.computeOrigins() + return this.origins.get(rowId) + } + + /** + * Row ids the user patch layers disable with a literal `disabled: true`. + * A `!!js` gate in a user file is a condition, not a user decision, and is + * left to the composition. + * @returns the ids, re-read from disk on every call. + */ + userDisabledRowIds(): Set { + const ids = new Set() + for (const patch of this.options.readUserPatches()) { + if (patch.insert !== undefined || patch.id === undefined) continue + if (patch.disabled === true && !isJsDisabled(patch.disabled)) ids.add(patch.id) + } + return ids + } + + /** + * Recompose the host tree from the profile's layers and the user patch files + * as they stand now. The root Include re-applies the stack transactionally: + * a row whose options changed is updated in place, a row that appeared is + * created, a row that vanished is disposed, and a failure rolls the whole + * update back with the previous tree still running. + * @param options - `reloadBundles` re-reads the profile manifest first, so a + * bundle enabled or installed since boot joins the stack. + * @throws when the root include is not mounted, or the Loader rejected the update. + */ + async recompose(options: { reloadBundles?: boolean } = {}): Promise { + const entry = this.options.rootEntry() + if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted') + if (options.reloadBundles === true) { + this.profile = this.options.loadProfile() + this.origins = undefined + } + const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config + await entry.update({ + config: { + ...includeConfig, + patches: this.options.compose(this.profile), + }, + }) + } + + private computeOrigins(): Map { + const origins = new Map() + for (const layer of this.profile.layers) { + const base = { trust: layer.trust, packageName: layer.packageName, ...layer.version === undefined ? {} : { version: layer.version } } + if (layer.trust === 'external' && layer.stage === 'runtime') { + for (const [id, origin] of composeExternalLayer(layer).rows) { + origins.set(id, { ...base, originalId: origin.originalId }) + } + continue + } + const ids = new Set() + insertedIds(layer.patches, ids) + for (const id of ids) origins.set(id, base) + } + return origins + } +} diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 52e8bcfafc..5178572b28 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -46,10 +46,21 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' /** Profile-private package links projected into its pnpm-managed node_modules. */ const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback' +/** + * When an external bundle's rows mount relative to the built-in tree. + * `runtime` (the default) wraps the bundle's inserted rows in a contained + * group whose failure is isolated and reported; `boot` mounts them like + * built-in rows, so a failure stops the process — the choice for a bundle + * that provides a service built-in rows inject. + */ +export type BundleStage = 'boot' | 'runtime' + /** The bundle half of the `dsh` manifest section: what a bundle package exports. */ export interface DshBundleManifest { /** The patch layer this bundle exports, relative to its package root. */ patch: string + /** Mount stage the bundle author asks for; the profile's `stages` overrides it. */ + stage?: BundleStage } /** The profile half of the `dsh` manifest section: what a profile directory composes. */ @@ -58,8 +69,23 @@ export interface DshProfileManifest { bundles?: string[] /** Whether user patch files reload while this profile remains active. */ patchReload?: ProfilePatchReload + /** Deployer overrides of each external bundle's mount stage, by package name. */ + stages?: Record + /** + * Installed packages treated as built-in: not wrapped, not prefixed, and + * fatal on failure. For first-party packages linked into a profile during + * development, where provenance alone would classify them external. + */ + firstParty?: string[] } +/** + * Who supplied a bundle layer. `builtin` layers come with the installation + * (template bundles) or are declared first-party by the profile; `external` + * layers are pnpm-managed dependencies the user installed. + */ +export type BundleTrust = 'builtin' | 'external' + /** User patch-file lifecycle selected by a profile. */ export type ProfilePatchReload = 'live' | 'startup' @@ -85,6 +111,8 @@ export interface DshManifestSection { /** The slice of package.json both profiles and bundles use. */ export interface ProfileManifest { name?: string + version?: string + description?: string dependencies?: Record peerDependencies?: Record dsh?: DshManifestSection @@ -94,10 +122,16 @@ export interface ProfileManifest { export interface ProfileLayer { /** The bundle's package name, as listed in `dsh.profile.bundles`. */ packageName: string + /** The bundle package's version, as its manifest declares it. */ + version: string | undefined /** Absolute directory of the resolved bundle package. */ packageDir: string /** Absolute path of the bundle's patch file. */ patchPath: string + /** Who supplied the layer; decides isolation, id prefixing, and failure semantics. */ + trust: BundleTrust + /** Effective mount stage: the profile's override, else the bundle's declaration, else `runtime`. */ + stage: BundleStage /** The parsed patch list. */ patches: PatchOptions[] } @@ -707,6 +741,18 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') } +/** + * Validate one bundle stage value read from a manifest; an unknown value is a + * misconfiguration and fails at load. + */ +function readBundleStage(binName: string, packageName: string, value: unknown): BundleStage { + if (value === undefined) return 'runtime' + if (value === 'boot' || value === 'runtime') return value + throw new Error( + `${binName}: bundle ${JSON.stringify(packageName)} declares stage ${JSON.stringify(value)}; expected "boot" or "runtime"`, + ) +} + /** Return whether two bundle lists have the same values in the same order. */ function sameBundles(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]) @@ -826,6 +872,9 @@ export function loadProfile( ) } const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD + const dependencies = new Set(Object.keys(manifest.dependencies ?? {})) + const firstParty = new Set(manifest.dsh?.profile?.firstParty ?? []) + const stages = manifest.dsh?.profile?.stages ?? {} const layers = bundles.map((packageName): ProfileLayer => { const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest @@ -834,7 +883,20 @@ export function loadProfile( throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } const patchPath = join(packageDir, declared) - return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + // Provenance, not the package name, decides trust: a template bundle is + // never a dependency, and everything pnpm added is out-of-tree — a fork + // that kept a first-party name still lands in `dependencies`. + const trust: BundleTrust = dependencies.has(packageName) && !firstParty.has(packageName) ? 'external' : 'builtin' + const stage = readBundleStage(binName, packageName, stages[packageName] ?? bundleManifest.dsh?.bundle?.stage) + return { + packageName, + version: bundleManifest.version, + packageDir, + patchPath, + trust, + stage, + patches: loadOverlayPatches(binName, patchPath), + } }) const patchPath = join(dir, PROFILE_PATCH_FILENAME) const patches = options.userLayer !== false && existsSync(patchPath) diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 644acd4892..4d8fc8c958 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -411,6 +411,7 @@ describe('installFailLoud', () => { installFailLoud(NAME, proc) const error = new Error('assembled activation failure') const audit = assertEntriesActivated({ + get: () => undefined, loader: { entries: () => ['broken-a', 'broken-b'].map(name => ({ options: { name }, @@ -494,7 +495,7 @@ describe('installFailLoud', () => { describe('assertEntriesLoaded', () => { const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context => - ({ loader: { entries: () => entries } }) as unknown as Context + ({ loader: { entries: () => entries }, get: () => undefined }) as unknown as Context it('passes when every enabled entry has a fiber', () => { expect(() => { assertEntriesLoaded(ctxWith([ @@ -522,6 +523,7 @@ describe('assertEntriesActivated', () => { const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({ loader: { entries: () => entries }, + get: () => undefined, }) as unknown as Context const fiber = ( diff --git a/packages/boot/app-boot/tests/contained-group.spec.ts b/packages/boot/app-boot/tests/contained-group.spec.ts new file mode 100644 index 0000000000..25875552c6 --- /dev/null +++ b/packages/boot/app-boot/tests/contained-group.spec.ts @@ -0,0 +1,247 @@ +/** + * Startup isolation: a row inside a `cordis:contained-group` fails on its + * own and is recorded, the group and its siblings stay up, and the boot + * audit treats the record as a report rather than a fatal. Built-in rows keep + * the fatal path, and a built-in row left waiting because an isolated bundle + * failed still fails the boot while naming that bundle. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Plugin } from '@deepseek-ai/cordis' +import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { + assertEntriesActivated, boot, ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, installRuntimeGuards, + isContainedEntry, rootIncludeEntry, warnNestedFiberFailures, type RuntimeGuardProcess, +} from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const contexts: Context[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +const good: Plugin.Function = () => {} +const throws: Plugin.Function = () => { throw new Error('boom at apply') } +const pending: Plugin.Object = { inject: ['neverReady'], apply() {} } +const nestedThrows: Plugin.Function = (ctx) => { + ctx.inject([], () => { throw new Error('nested boom') }) +} +let flakyCalls = 0 +const flaky: Plugin.Function = () => { + flakyCalls += 1 + if (flakyCalls > 1) throw new Error('flaky on reload') +} + +const prepare = (ctx: Context): void => { + ctx.loader.builtins.good = good + ctx.loader.builtins.throws = throws + ctx.loader.builtins.pending = pending + ctx.loader.builtins['nested-throws'] = nestedThrows + ctx.loader.builtins.flaky = flaky +} + +function stage(config: string): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-contained-')) + const path = join(dir, 'cordis.yml') + writeFileSync(path, config) + return path +} + +const settle = (): Promise => new Promise(resolve => setTimeout(resolve, 30)) + +describe('cordis:contained-group', () => { + it('isolates a failing row, keeps its siblings, and records the failure', async () => { + const ctx = await boot(NAME, stage(` +- id: bundle/ext + name: cordis:contained-group + group: true + config: + - id: ext/ok + name: cordis:good + - id: ext/bad + name: cordis:throws +- id: builtin-ok + name: cordis:good +`), [], prepare) + contexts.push(ctx) + // Tree-wide ids carry the root include's prefix; the composition's own ids do not. + const ids = [...ctx.loader.entries()].map(entry => entry.id) + expect(ids).toEqual(expect.arrayContaining(['include:bundle/ext', 'include:ext/ok', 'include:builtin-ok'])) + expect(ids).not.toContain('include:ext/bad') + const registry = ctx.get('pluginFailures') as ContainedFailureRegistry + expect(registry.list()).toEqual([expect.objectContaining({ + entryId: 'include:ext/bad', rowId: 'ext/bad', moduleName: 'cordis:throws', groupId: 'include:bundle/ext', stage: 'apply', + })]) + expect(registry.get('include:ext/bad')?.message).toContain('boom at apply') + expect(isContainedEntry(ctx.loader.resolve('include:ext/ok'))).toBe(true) + expect(isContainedEntry(ctx.loader.resolve('include:builtin-ok'))).toBe(false) + expect(rootIncludeEntry(ctx)?.id).toBe('include') + }) + + it('records a contained row that waits forever for a service instead of failing the boot', async () => { + const ctx = await boot(NAME, stage(` +- id: bundle/ext + name: cordis:contained-group + group: true + config: + - id: ext/waiting + name: cordis:pending +`), [], prepare) + contexts.push(ctx) + const registry = ctx.get('pluginFailures') as ContainedFailureRegistry + expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', rowId: 'ext/waiting' })) + expect(registry.get('include:ext/waiting')?.message).toContain('neverReady') + }) + + it('still fails the boot for a built-in row, and names isolated bundles when a built-in row is left waiting', async () => { + await expect(boot(NAME, stage(` +- id: builtin-bad + name: cordis:throws +`), [], prepare)).rejects.toThrow(/boom at apply/) + + await expect(boot(NAME, stage(` +- id: bundle/ext + name: cordis:contained-group + group: true + config: + - id: ext/bad + name: cordis:throws +- id: builtin-waiting + name: cordis:pending +`), [], prepare)).rejects.toThrow(/isolated bundle failure\(s\) may be the missing provider — check include:bundle\/ext/) + }) + + it('re-running the audit clears a record whose row later mounted', async () => { + const ctx = await boot(NAME, stage(` +- id: bundle/ext + name: cordis:contained-group + group: true + config: + - id: ext/ok + name: cordis:good +`), [], prepare) + contexts.push(ctx) + const registry = ctx.get('pluginFailures') as ContainedFailureRegistry + registry.record({ entryId: 'include:ext/ok', rowId: 'ext/ok', moduleName: 'cordis:good', groupId: 'include:bundle/ext', stage: 'unknown', message: 'stale' }) + await assertEntriesActivated(ctx, NAME) + expect(registry.get('include:ext/ok')).toBeUndefined() + }) + + it('records an import failure, and spells ids without a prefix in a tree with no include', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins['contained-group'] = ContainedGroup + ensurePluginFailures(ctx) + const group: EntryOptions = { + id: 'bundle/ext', name: 'cordis:contained-group', group: true, + config: [{ id: 'ext/missing', name: 'no-such-package-for-dsh-tests' }], + } + await ctx.loader.create(group) + const registry = ctx.get('pluginFailures') as ContainedFailureRegistry + expect(registry.get('ext/missing')).toEqual(expect.objectContaining({ entryId: 'ext/missing', rowId: 'ext/missing', stage: 'import', groupId: 'bundle/ext' })) + }) + + it('records a contained row whose fiber fails on a later reload', async () => { + flakyCalls = 0 + const ctx = await boot(NAME, stage(` +- id: bundle/ext + name: cordis:contained-group + group: true + config: + - id: ext/flaky + name: cordis:flaky +`), [], prepare) + contexts.push(ctx) + const entry = ctx.loader.resolve('include:ext/flaky') + // The restart rethrows the reload failure; the fiber stays mounted as FAILED. + await entry.fiber?.restart().catch(() => undefined) + await settle() + await assertEntriesActivated(ctx, NAME) + const registry = ctx.get('pluginFailures') as ContainedFailureRegistry + expect(registry.get('include:ext/flaky')).toEqual(expect.objectContaining({ stage: 'apply', rowId: 'ext/flaky' })) + expect(registry.get('include:ext/flaky')?.message).toContain('flaky on reload') + }) + + it('provides one registry per runtime', async () => { + const ctx = new Context() + contexts.push(ctx) + const registry = ensurePluginFailures(ctx) + expect(ensurePluginFailures(ctx.extend({}))).toBe(registry) + registry.record({ entryId: 'a', rowId: 'a', moduleName: 'm', groupId: 'g', stage: 'import', message: 'x' }) + registry.record({ entryId: 'a', rowId: 'a', moduleName: 'm', groupId: 'g', stage: 'apply', message: 'y' }) + expect(registry.list()).toHaveLength(1) + registry.clear('a') + expect(registry.list()).toEqual([]) + }) +}) + +describe('warnNestedFiberFailures', () => { + it('reports a failed nested fiber under a built-in entry and ignores contained ones', async () => { + const ctx = await boot(NAME, stage(` +- id: builtin-nested + name: cordis:nested-throws +- id: bundle/ext + name: cordis:contained-group + group: true + config: + - id: ext/nested + name: cordis:nested-throws +`), [], prepare) + contexts.push(ctx) + await settle() + const lines: string[] = [] + expect(warnNestedFiberFailures(ctx, NAME, line => lines.push(line))).toBe(1) + expect(lines).toEqual([expect.stringContaining('cordis:nested-throws')]) + // The default sink is the context logger; it must accept the call too. + expect(warnNestedFiberFailures(ctx, NAME)).toBe(1) + }) + + it('reports nothing for a clean tree', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + expect(warnNestedFiberFailures(ctx, NAME, () => { throw new Error('unexpected') })).toBe(0) + }) +}) + +describe('installRuntimeGuards', () => { + function fakeProc(): RuntimeGuardProcess & { handlers: Map void>; exits: number[] } { + const handlers = new Map void>() + const exits: number[] = [] + return { + handlers, + exits, + on: (event, handler) => { handlers.set(event, handler) }, + off: (event) => { handlers.delete(event) }, + exit: (code) => { exits.push(code) }, + } + } + + it('reports a rejection without exiting, exits on an exception, and uninstalls both', () => { + const proc = fakeProc() + const lines: string[] = [] + const uninstall = installRuntimeGuards(NAME, line => lines.push(line), proc) + proc.handlers.get('unhandledRejection')?.(new Error('stray')) + expect(proc.exits).toEqual([]) + expect(lines[0]).toContain('unhandled rejection after boot') + expect(lines[0]).toContain('stray') + proc.handlers.get('uncaughtException')?.('plain') + expect(proc.exits).toEqual([1]) + expect(lines[1]).toContain('uncaught exception after boot') + uninstall() + expect(proc.handlers.size).toBe(0) + }) + + it('defaults to the real process', () => { + const before = process.listenerCount('uncaughtException') + const uninstall = installRuntimeGuards(NAME, () => {}) + expect(process.listenerCount('uncaughtException')).toBe(before + 1) + uninstall() + expect(process.listenerCount('uncaughtException')).toBe(before) + }) +}) diff --git a/packages/boot/app-boot/tests/external-bundles.spec.ts b/packages/boot/app-boot/tests/external-bundles.spec.ts new file mode 100644 index 0000000000..0b7c2d086b --- /dev/null +++ b/packages/boot/app-boot/tests/external-bundles.spec.ts @@ -0,0 +1,196 @@ +/** + * External bundle composition (one contained, id-prefixed group per bundle) + * and the manifest operations behind installing, enabling, and disabling. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { + bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle, exportsBundlePatch, + externalRowId, isJsDisabled, reconcileInstalledBundles, readProfileManifest, type ProfileLayer, +} from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-external-bundles-')) + +function layer(packageName: string, patches: PatchOptions[]): ProfileLayer { + return { + packageName, version: '1.0.0', packageDir: '/nowhere', patchPath: '/nowhere/cordis.patch.yml', + trust: 'external', stage: 'runtime', patches, + } +} + +describe('composeExternalLayer', () => { + it('wraps root inserts in one contained group and prefixes every row id', () => { + const composed = composeExternalLayer(layer('pkg-a', [ + { insert: [{ id: 'tool', name: 'pkg-a' }, { name: 'pkg-a/anonymous' } as EntryOptions] }, + { insert: [{ id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] }] }, + ])) + expect(composed.patches).toEqual([{ + insert: [{ + id: 'bundle/pkg-a', + name: CONTAINED_GROUP_MODULE, + group: true, + config: [ + { id: 'pkg-a/tool', name: 'pkg-a' }, + { name: 'pkg-a/anonymous' }, + { id: 'pkg-a/grp', name: 'cordis:group', group: true, config: [{ id: 'pkg-a/inner', name: 'pkg-a/inner' }] }, + ], + }], + }]) + expect([...composed.rows.keys()]).toEqual(['pkg-a/tool', 'pkg-a/grp', 'pkg-a/inner', 'bundle/pkg-a']) + expect(composed.rows.get('pkg-a/inner')).toEqual({ packageName: 'pkg-a', originalId: 'inner' }) + expect(composed.overrides).toEqual([]) + }) + + it('rewrites patches that address the bundle\'s own rows and passes overrides of built-in rows through', () => { + const composed = composeExternalLayer(layer('pkg-b', [ + { insert: [{ id: 'own', name: 'pkg-b' }] }, + { id: 'own', config: { flag: true } }, + { id: 'settings', config: { path: '/x' } }, + { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] }, + ])) + expect(composed.patches.slice(1)).toEqual([ + { id: 'pkg-b/own', insert: [{ id: 'pkg-b/child', name: 'pkg-b/child' }] }, + { id: 'pkg-b/own', config: { flag: true } }, + { id: 'settings', config: { path: '/x' } }, + ]) + expect(composed.overrides).toEqual(['settings']) + }) + + it('nests rows inserted into a built-in group inside their own contained group', () => { + const composed = composeExternalLayer(layer('pkg-c', [ + { id: 'persistent-shell', insert: [{ id: 'extra', name: 'pkg-c/extra' }] }, + ])) + expect(composed.patches).toEqual([ + { insert: [{ id: 'bundle/pkg-c', name: CONTAINED_GROUP_MODULE, group: true, config: [] }] }, + { + id: 'persistent-shell', + insert: [{ id: 'bundle/pkg-c/in/persistent-shell', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'pkg-c/extra', name: 'pkg-c/extra' }] }], + }, + ]) + expect(composed.rows.has('bundle/pkg-c/in/persistent-shell')).toBe(true) + }) + + it('never mutates the layer\'s own patch objects', () => { + const patches: PatchOptions[] = [{ insert: [{ id: 'row', name: 'pkg-d' }] }, { id: 'row', config: { a: 1 } }] + const snapshot = structuredClone(patches) + composeExternalLayer(layer('pkg-d', patches)) + expect(patches).toEqual(snapshot) + }) + + it('spells ids without the Loader\'s nested-id separator', () => { + expect(bundleGroupId('@scope/pkg')).toBe('bundle/@scope/pkg') + expect(externalRowId('@scope/pkg', 'row')).toBe('@scope/pkg/row') + expect(bundleGroupId('x')).not.toContain(':') + }) + + it('tells a !!js disabled node from a literal', () => { + expect(isJsDisabled({ __jsExpr: 'true' })).toBe(true) + expect(isJsDisabled(true)).toBe(false) + }) +}) + +/** Stage a profile directory whose node_modules holds the named packages. */ +function stageProfile(packages: Record): { profileDir: string; installAnchor: string } { + const root = tmp() + const appDir = join(root, 'app') + mkdirSync(join(appDir, 'node_modules'), { recursive: true }) + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', version: '0.0.0' })) + const profileDir = join(root, 'profile') + mkdirSync(join(profileDir, 'node_modules'), { recursive: true }) + const dependencies: Record = {} + for (const [name, spec] of Object.entries(packages)) { + const dir = join(profileDir, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name, version: '1.2.3', ...spec.bundle === true ? { dsh: { bundle: { patch: './cordis.patch.yml' } } } : {}, + })) + if (spec.bundle === true) writeFileSync(join(dir, 'cordis.patch.yml'), `- insert:\n - id: row\n name: ${name}\n`) + if (spec.dependency !== false) dependencies[name] = '1.2.3' + } + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-test', private: true, dependencies, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } }, + })) + return { profileDir, installAnchor: join(appDir, 'package.json') } +} + +describe('reconcileInstalledBundles', () => { + it('tells bundles from plain packages and enables new bundles only when asked', () => { + const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true }, 'plain-lib': {} }) + const before = { dependencies: {} } + + const installedOnly = reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false }) + expect(installedOnly).toEqual({ enabled: [], removed: [], plain: ['plain-lib'], installedOnly: ['ext-bundle'] }) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + + const enabled = reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: true }) + expect(enabled).toEqual({ enabled: ['ext-bundle'], removed: [], plain: ['plain-lib'], installedOnly: [] }) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'ext-bundle']) + expect(exportsBundlePatch(NAME, 'plain-lib', installAnchor, profileDir)).toBe(false) + expect(exportsBundlePatch(NAME, 'missing', installAnchor, profileDir)).toBe(false) + }) + + it('drops a layer whose dependency was removed and keeps template bundles', () => { + const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } }) + enableBundle(NAME, profileDir, installAnchor, 'ext-bundle') + const manifest = readProfileManifest(NAME, profileDir) + // pnpm removed the dependency; the layer list still names it. + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ ...manifest, dependencies: {} })) + + const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, manifest, { autoEnable: true }) + expect(outcome.removed).toEqual(['ext-bundle']) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + }) +}) + +describe('reconcileInstalledBundles on a minimal manifest', () => { + it('tolerates manifests without dependencies or a bundle list and skips already-known plain packages', () => { + const { profileDir, installAnchor } = stageProfile({ 'plain-lib': {}, 'ext-bundle': { bundle: true } }) + const manifest = readProfileManifest(NAME, profileDir) + // No dsh section and no bundle list at all: the layer list starts empty. + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'bare', dependencies: manifest.dependencies })) + const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, { dependencies: { 'plain-lib': '1.2.3' } }, { autoEnable: true }) + expect(outcome).toEqual({ enabled: ['ext-bundle'], removed: [], plain: [], installedOnly: [] }) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['ext-bundle']) + // A manifest with neither section reconciles to nothing. + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'empty' })) + expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, {}, { autoEnable: true })) + .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] }) + }) + + it('enables and disables against a manifest that never had a bundle list', () => { + const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'bare', dependencies: { 'ext-bundle': '1.2.3' } })) + expect(disableBundle(NAME, profileDir, 'ext-bundle')).toBe(false) + expect(enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toBe(true) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['ext-bundle']) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'bare', dsh: { profile: { bundles: ['ext-bundle'] } } })) + expect(() => disableBundle(NAME, profileDir, 'ext-bundle')).toThrow(/template bundle/) + expect(() => enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toThrow(/is not installed/) + }) +}) + +describe('enableBundle / disableBundle', () => { + it('appends and removes dependency-managed bundles, reporting no-ops', () => { + const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } }) + expect(enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toBe(true) + expect(enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toBe(false) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'ext-bundle']) + expect(disableBundle(NAME, profileDir, 'ext-bundle')).toBe(true) + expect(disableBundle(NAME, profileDir, 'ext-bundle')).toBe(false) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + }) + + it('refuses a package that is not installed, not a bundle, or a template bundle', () => { + const { profileDir, installAnchor } = stageProfile({ 'plain-lib': {} }) + expect(() => enableBundle(NAME, profileDir, installAnchor, 'ghost')).toThrow(/is not installed in profile/) + expect(() => enableBundle(NAME, profileDir, installAnchor, 'plain-lib')).toThrow(/declares no dsh\.bundle/) + expect(() => disableBundle(NAME, profileDir, '@deepseek-ai/dsh-base')).toThrow(/template bundle/) + }) +}) diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts new file mode 100644 index 0000000000..c8918d73d6 --- /dev/null +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -0,0 +1,191 @@ +/** + * The install-time probe: manifest facts read in-process, imports checked in + * a child process, and the per-profile cache. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +interface StagedPackage { + main?: string + manifest?: Record + files?: Record +} + +/** Stage a profile with packages under its node_modules and an empty app anchor. */ +function stage(packages: Record): { profileDir: string; installAnchor: string } { + const root = mkdtempSync(join(tmpdir(), 'dsh-probe-')) + const appDir = join(root, 'app') + mkdirSync(join(appDir, 'node_modules'), { recursive: true }) + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', version: '0.0.0' })) + const profileDir = join(root, 'profile') + mkdirSync(join(profileDir, 'node_modules'), { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', private: true })) + for (const [name, spec] of Object.entries(packages)) { + const dir = join(profileDir, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name, version: '1.0.0', type: 'module', ...spec.main === undefined ? {} : { main: './index.js' }, ...spec.manifest, + })) + if (spec.main !== undefined) writeFileSync(join(dir, 'index.js'), spec.main) + for (const [file, content] of Object.entries(spec.files ?? {})) { + mkdirSync(join(dir, file, '..'), { recursive: true }) + writeFileSync(join(dir, file), content) + } + } + return { profileDir, installAnchor: join(appDir, 'package.json') } +} + +const PLUGIN_MAIN = ` +export const name = 'probe-plugin' +export const Config = { toJSON: () => ({ type: 'object', properties: { flag: { type: 'boolean' } } }) } +export function apply() {} +` + +describe('probePackage', () => { + it('classifies a bundle, lists its rows and overrides, and keeps the config schema', async () => { + const { profileDir, installAnchor } = stage({ + 'ext-bundle': { + main: PLUGIN_MAIN, + manifest: { + description: 'A test bundle', + engines: { dsh: '>=0.1.0' }, + dsh: { title: 'Ext', bundle: { patch: './cordis.patch.yml' }, plugins: [{ name: 'ext-bundle', title: 'Main as row', config: { a: 1 } }] }, + }, + files: { + 'cordis.patch.yml': [ + '- insert:', + ' - id: seam', + ' name: other-pkg', + " disabled: !!js 'false'", + ' - id: ext', + ' name: ext-bundle', + ' - name: ext-bundle/anonymous', + ' - id: grp', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: nested', + ' name: ext-bundle/nested', + '- id: ext', + ' config: { flag: true }', + '- id: settings', + ' config: { path: /x }', + '', + ].join('\n'), + }, + }, + }) + const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ext-bundle' }) + expect(probe).toMatchObject({ + packageName: 'ext-bundle', version: '1.0.0', description: 'A test bundle', title: 'Ext', kind: 'bundle', ok: true, + enginesDsh: '>=0.1.0', overrides: ['settings'], + rows: [ + { id: 'seam', name: 'other-pkg', gated: true }, + { id: 'ext', name: 'ext-bundle', gated: false }, + { name: 'ext-bundle/anonymous', gated: false }, + { id: 'grp', name: 'cordis:group', gated: false }, + { id: 'nested', name: 'ext-bundle/nested', gated: false }, + ], + configSchema: { type: 'object', properties: { flag: { type: 'boolean' } } }, + }) + expect(probe.addable).toEqual([{ name: 'ext-bundle', title: 'Main as row', config: { a: 1 }, ok: true, configSchema: { type: 'object', properties: { flag: { type: 'boolean' } } } }]) + expect(probe.cordisSameCopy).toBeNull() + expect(Date.parse(probe.checkedAt)).not.toBeNaN() + }) + + it('classifies a plain plugin package and a library', async () => { + const { profileDir, installAnchor } = stage({ + 'plain-plugin': { main: 'export function apply() {}\n' }, + 'plain-lib': { main: 'export const helper = 1\n' }, + }) + expect((await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'plain-plugin' })).kind).toBe('plugin') + const lib = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'plain-lib' }) + expect(lib.kind).toBe('library') + expect(lib.ok).toBe(true) + }) + + it('reports a package that fails to import, and one that brings its own cordis', async () => { + const { profileDir, installAnchor } = stage({ + 'broken': { main: 'throw new Error("import-time explosion")\n' }, + 'own-cordis': { + main: 'export function apply() {}\n', + files: { + 'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ name: '@deepseek-ai/cordis', version: '0.0.0', type: 'module', main: './index.js' }), + 'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n', + }, + }, + }) + const broken = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'broken' }) + expect(broken.ok).toBe(false) + expect(broken.reason).toContain('import-time explosion') + expect(broken.kind).toBe('library') + + const own = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'own-cordis' }) + expect(own.cordisSameCopy).toBe(false) + expect(own.ok).toBe(false) + expect(own.reason).toContain('own copy of @deepseek-ai/cordis') + }) + + it('probes a package with the minimal manifest and no main export', async () => { + const { profileDir, installAnchor } = stage({ + 'minimal': { manifest: { version: undefined } }, + }) + const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'minimal' }) + // No version, description, title, engines, or main: only the facts every package has. + expect(probe).toEqual({ + packageName: 'minimal', kind: 'library', ok: true, cordisSameCopy: null, + rows: [], overrides: [], addable: [], checkedAt: expect.any(String) as string, + }) + }) + + it('records an addable module that fails to import without failing the package', async () => { + const { profileDir, installAnchor } = stage({ + 'partial': { + main: 'export function apply() {}\n', + manifest: { dsh: { plugins: [{ name: 'partial/missing' }] } }, + }, + }) + const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'partial' }) + expect(probe.ok).toBe(true) + expect(probe.addable).toEqual([expect.objectContaining({ name: 'partial/missing', ok: false })]) + expect(probe.addable[0]?.error).toBeDefined() + }) + + it('kills a child that never reports and refuses an unresolvable package', async () => { + const { profileDir, installAnchor } = stage({ + 'hangs': { main: 'setInterval(() => {}, 1000)\nexport function apply() {}\n' }, + 'exits': { main: 'process.stderr.write("refusing to report"); process.exit(3)\n' }, + }) + await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'hangs', timeoutMs: 300 })) + .rejects.toThrow(/timed out after 300ms/) + await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits' })) + .rejects.toThrow(/exited with 3 without a report: refusing to report/) + await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ghost' })) + .rejects.toThrow(/cannot resolve profile bundle "ghost"/) + await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits', nodeExecutable: '/no/such/node' })) + .rejects.toThrow(/ENOENT/) + }) +}) + +describe('probe cache', () => { + it('round-trips a record per package and treats a different version as absent', () => { + const profileDir = mkdtempSync(join(tmpdir(), 'dsh-probe-cache-')) + const record: PluginProbe = { + packageName: '@scope/pkg', version: '1.0.0', kind: 'plugin', ok: true, cordisSameCopy: null, + rows: [], overrides: [], addable: [], checkedAt: '2026-09-04T00:00:00.000Z', + } + expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined() + writeProbeCache(profileDir, record) + expect(readProbeCache(profileDir, '@scope/pkg')).toEqual(record) + expect(readProbeCache(profileDir, '@scope/pkg', '1.0.0')).toEqual(record) + expect(readProbeCache(profileDir, '@scope/pkg', '2.0.0')).toBeUndefined() + writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), '{ not json') + expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined() + }) +}) diff --git a/packages/boot/app-boot/tests/profile-runtime.spec.ts b/packages/boot/app-boot/tests/profile-runtime.spec.ts new file mode 100644 index 0000000000..b7cbb9cae7 --- /dev/null +++ b/packages/boot/app-boot/tests/profile-runtime.spec.ts @@ -0,0 +1,113 @@ +/** + * The `profileRuntime` service: profile facts, row provenance, user-disabled + * rows, and recomposition through the root include entry. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { ProfileRuntime, type Profile, type ProfileLayer } from '../src/index.ts' + +const contexts: Context[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function layer(packageName: string, trust: ProfileLayer['trust'], patches: PatchOptions[], stage: ProfileLayer['stage'] = 'runtime'): ProfileLayer { + return { packageName, version: '2.0.0', packageDir: '/nowhere', patchPath: '/nowhere/p.yml', trust, stage, patches } +} + +function profile(layers: ProfileLayer[]): Profile { + return { name: 'web', dir: '/profiles/web', layers, patchPath: '/profiles/web/cordis.patch.yml', patches: [], patchReload: 'live' } +} + +async function harness( + layers: ProfileLayer[], + options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile } = {}, +): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType }> { + const ctx = new Context() + contexts.push(ctx) + const compose = vi.fn((current: Profile) => [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]) + await ctx.plugin(ProfileRuntime, { + profile: profile(layers), + loadProfile: () => options.reloaded ?? profile(layers), + compose, + rootEntry: options.rootEntry ?? (() => undefined), + readUserPatches: () => options.userPatches ?? [], + }) + return { ctx, runtime: ctx.profileRuntime, compose } +} + +describe('ProfileRuntime', () => { + it('exposes the booted profile\'s facts', async () => { + const { runtime } = await harness([layer('@deepseek-ai/dsh-base', 'builtin', [])]) + expect(runtime.profileName).toBe('web') + expect(runtime.dir).toBe('/profiles/web') + expect(runtime.patchPath).toBe('/profiles/web/cordis.patch.yml') + expect(runtime.patchReload).toBe('live') + expect(runtime.layers.map(l => l.packageName)).toEqual(['@deepseek-ai/dsh-base']) + expect(runtime.current.name).toBe('web') + }) + + it('attributes rows to the layer that inserted them, prefixed for external layers', async () => { + const { runtime } = await harness([ + layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [{ id: 'settings', name: 'x' }, { name: 'anonymous' } as EntryOptions, { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'y' }] }] }]), + layer('ext', 'external', [{ insert: [{ id: 'tool', name: 'ext' }] }]), + layer('boot-ext', 'external', [{ insert: [{ id: 'svc', name: 'boot-ext' }] }], 'boot'), + ]) + expect(runtime.originOf('settings')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' }) + expect(runtime.originOf('child')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' }) + expect(runtime.originOf('ext/tool')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0', originalId: 'tool' }) + expect(runtime.originOf('bundle/ext')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0', originalId: 'bundle/ext' }) + // A boot-stage external layer mounts unprefixed, like a built-in one. + expect(runtime.originOf('svc')).toEqual({ trust: 'external', packageName: 'boot-ext', version: '2.0.0' }) + expect(runtime.originOf('user-row')).toBeUndefined() + }) + + it('does not descend into a group row whose config is not a list', async () => { + const { runtime } = await harness([ + layer('odd', 'builtin', [{ insert: [{ id: 'g', name: 'cordis:group', group: true, config: {} as never }] }]), + ]) + expect(runtime.originOf('g')?.packageName).toBe('odd') + }) + + it('omits the version when the layer has none', async () => { + const { runtime } = await harness([{ ...layer('local', 'builtin', [{ insert: [{ id: 'r', name: 'local' }] }]), version: undefined }]) + expect(runtime.originOf('r')).toEqual({ trust: 'builtin', packageName: 'local' }) + }) + + it('reads user-disabled rows from literal disabled: true items only', async () => { + const { runtime } = await harness([], { + userPatches: [ + { id: 'a', disabled: true }, + { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean }, + { id: 'c', config: {} }, + { insert: [{ id: 'd', name: 'x', disabled: true }] }, + ], + }) + expect([...runtime.userDisabledRowIds()]).toEqual(['a']) + }) + + it('recomposes through the root include, optionally re-reading the profile first', async () => { + const update = vi.fn(async () => {}) + const entry = { options: { config: { path: 'file:///root/cordis.yml', patches: [{ id: 'old' }] } }, update } as unknown as Entry + const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])]) + const { runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded }) + + await runtime.recompose() + expect(compose).toHaveBeenLastCalledWith(expect.objectContaining({ layers: expect.any(Array) as ProfileLayer[] })) + expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-1' }] } }) + + await runtime.recompose({ reloadBundles: true }) + expect(runtime.layers).toHaveLength(2) + expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } }) + // Provenance follows the reloaded profile. + expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0', originalId: 'bundle/b' }) + }) + + it('refuses to recompose before the root include is mounted', async () => { + const { runtime } = await harness([]) + await expect(runtime.recompose()).rejects.toThrow(/root include is not mounted/) + }) +}) diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 0681157f62..89a4bbdc31 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -72,6 +72,9 @@ function stageProfile(home: string, name: string, bundleAnchor: string): Profile packageDir: join(bundleAnchor, '..'), patchPath: join(bundleAnchor, '..', 'cordis.patch.yml'), patches: [], + version: undefined, + trust: 'builtin', + stage: 'runtime', }], patchPath: join(dir, PROFILE_PATCH_FILENAME), patches: [], @@ -181,6 +184,51 @@ describe('loadProfile', () => { expect(bare.patchReload).toBe('live') }) + it('classifies layers by provenance and resolves each bundle\'s mount stage', () => { + const anchor = stageInstallation({ + 'in-box': { patch: '[]\n' }, + 'ext-runtime': { patch: '[]\n' }, + 'ext-boot': { patch: '[]\n' }, + 'ext-overridden': { patch: '[]\n' }, + 'ext-first-party': { patch: '[]\n' }, + }) + // The bundle author asks for boot stage on one package. + const bootManifest = join(anchor, '..', 'node_modules', 'ext-boot', 'package.json') + writeFileSync(bootManifest, JSON.stringify({ ...JSON.parse(readFileSync(bootManifest, 'utf8')) as object, dsh: { bundle: { patch: './cordis.patch.yml', stage: 'boot' } } })) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['in-box', 'ext-runtime', 'ext-boot', 'ext-overridden', 'ext-first-party']) + writeProfileManifest(dir, { + ...readProfileManifest('t', dir), + dependencies: { 'ext-runtime': '0.0.0', 'ext-boot': '0.0.0', 'ext-overridden': '0.0.0', 'ext-first-party': '0.0.0' }, + dsh: { + profile: { + bundles: ['in-box', 'ext-runtime', 'ext-boot', 'ext-overridden', 'ext-first-party'], + stages: { 'ext-overridden': 'boot' }, + firstParty: ['ext-first-party'], + }, + }, + }) + const profile = loadProfile('t', 'demo', anchor, home) + expect(profile.layers.map(layer => [layer.packageName, layer.trust, layer.stage, layer.version])).toEqual([ + ['in-box', 'builtin', 'runtime', '0.0.0'], + ['ext-runtime', 'external', 'runtime', '0.0.0'], + ['ext-boot', 'external', 'boot', '0.0.0'], + ['ext-overridden', 'external', 'boot', '0.0.0'], + ['ext-first-party', 'builtin', 'runtime', '0.0.0'], + ]) + }) + + it('fails loud on a stage value it does not know', () => { + const anchor = stageInstallation({ 'ext-odd': { patch: '[]\n' } }) + const oddManifest = join(anchor, '..', 'node_modules', 'ext-odd', 'package.json') + writeFileSync(oddManifest, JSON.stringify({ ...JSON.parse(readFileSync(oddManifest, 'utf8')) as object, dsh: { bundle: { patch: './cordis.patch.yml', stage: 'later' } } })) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['ext-odd']) + expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares stage "later"; expected "boot" or "runtime"') + }) + it('auto-initializes only shipped templates and fails loud otherwise', () => { const anchor = stageInstallation({}) const home = tmp() @@ -419,6 +467,9 @@ describe('healProfilesModuleFallback', () => { packageDir: bundleLink, patchPath: join(bundleLink, 'cordis.patch.yml'), patches: [], + version: undefined, + trust: 'builtin' as const, + stage: 'runtime' as const, }], patchPath: join(dir, PROFILE_PATCH_FILENAME), patches: [], @@ -464,6 +515,9 @@ describe('healProfilesModuleFallback', () => { packageDir, patchPath: join(packageDir, 'cordis.patch.yml'), patches: [], + version: undefined, + trust: 'builtin' as const, + stage: 'runtime' as const, })), patchPath: join(dir, PROFILE_PATCH_FILENAME), patches: [], @@ -504,6 +558,9 @@ describe('healProfilesModuleFallback', () => { packageName, patchPath: join(packageDir, 'cordis.patch.yml'), patches: [], + version: undefined, + trust: 'builtin' as const, + stage: 'runtime' as const, })), patchPath: join(dir, PROFILE_PATCH_FILENAME), patches: [], diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 877fd07d0f..12324f392c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1287,6 +1287,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'profileRuntime', + summary: 'Facts and recomposition of the booted profile.', + description: 'Facts and recomposition of the booted profile.', + methods: [ + { + signature: 'originOf(rowId: string): RowOrigin | undefined', + description: 'Where one mounted row came from.', + parameters: [{ name: 'rowId', description: 'the row\'s tree-wide id.' }], + returns: 'the origin, or undefined for a row no bundle layer inserted (a user or overlay row).', + }, + { + signature: 'userDisabledRowIds(): Set', + description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file is a condition, not a user decision, and is left to the composition.', + parameters: [], + returns: 'the ids, re-read from disk on every call.', + }, + { + signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise', + description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running.', + parameters: [{ name: 'options', description: '`reloadBundles` re-reads the profile manifest first, so a bundle enabled or installed since boot joins the stack.' }], + throws: ['when the root include is not mounted, or the Loader rejected the update.'], + }, + ], + }, { key: 'sandbox', summary: 'Abstract process-sandbox service.', @@ -3594,6 +3619,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BrandedNumber', declaration: 'export type BrandedNumber = number & {\n readonly [BRAND]: B;\n};', }, + { + name: 'BundleTrust', + declaration: 'export type BundleTrust = \'builtin\' | \'external\';', + }, { name: 'ClientArtifactBaseline', declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}', @@ -4690,6 +4719,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, + { + name: 'RowOrigin', + declaration: 'export interface RowOrigin {\n readonly trust: BundleTrust;\n readonly packageName: string;\n readonly version?: string;\n readonly originalId?: string;\n}', + }, { name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 6cb6bc34ff..1967dba2c5 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -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/host/plugin-inventory/README.md -README.md: c5d8bfc094571709007a440ead4a701f127e0e18 -README.zh.md: 9b6d4ac72c2e5eca4b16c9fa12fcce7cccc1544f +README.md: 0eaf395ec9ecbdaf56c26c9adc8498b0e6ebaaec +README.zh.md: 60140b175c7f4f7b28cd35e710b92af4c2deaa5d diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index c5d8bfc094..0eaf395ec9 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -31,6 +31,8 @@ Call `pluginInventory/list` when a client or settings page needs to show what is Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped. +When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles and `external` for a row of a bundle the user installed, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure. + ### Per-preset compositions With a roster composed, `agentPresets` carries one group per preset in roster order: its id, whether the deployment ships it or the user owns it (`trust`, which clients use to localize shipped names), published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, and a root-fiber phase when the composition is live. A preset some session already composed answers from its newest standing generation — even when its file has since broken, because the mount is what those sessions run; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset nothing composed stays listed with its reason and no rows. Without a roster the field is absent. @@ -97,7 +99,7 @@ None; this package neither assembles nor sends a provider request. These limits define what a point-in-time inventory cannot tell a client. They are current package constraints, not a task backlog. - **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. -- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins in either plane. +- **No mutation** — the service cannot enable, disable, add, or remove plugins in either plane; provenance stops at the bundle layer, so a row a user patch or a `--patch` overlay inserted carries no package. - **Presets appear only with a roster** — a deployment without `dsh-agent-presets` serves Loader entries alone; the `agentPresets` field is absent rather than empty. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 9b6d4ac72c..60140b175c 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。 +当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。 + ### 每个预设的组合 组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、随部署内置还是用户自建(`trust`,客户端据此本地化内置预设名)、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有),以及组合存活时的根 Fiber 阶段。已有会话组合过的预设由其最新 standing 世代作答——即使其文件事后损坏也是如此,因为挂载才是这些会话实际运行的组合;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;无人组合的坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。 @@ -97,7 +99,7 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产 这些限制说明一个点时刻清单无法告诉客户端什么。它们是当前包约束,不是任务积压。 - **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 -- **无来源与修改能力**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能在任一平面启用、停用、添加或移除插件。 +- **无修改能力**——服务不能在任一平面启用、停用、添加或移除插件;来源只追溯到组合包层,用户 patch 或 `--patch` overlay 插入的行不带 package。 - **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 9df3be70b0..c80e38371f 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -47,22 +47,27 @@ "zod": "^4.4.3" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-agent-presets": { "optional": true + }, + "@deepseek-ai/dsh-app-boot": { + "optional": true } }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^" } } diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 3beef6f0c0..81c1bc5328 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -4,6 +4,9 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' // Type-only: the optional agent-preset roster resolved through `ctx.get`. import type {} from '@deepseek-ai/dsh-agent-presets' +// Type-only: the optional profile runtime and contained-failure registry the +// boot glue provides, both resolved through `ctx.get`. +import type {} from '@deepseek-ai/dsh-app-boot' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' @@ -13,6 +16,7 @@ import type { PluginFiberPhase, PluginInventoryEntry, PluginInventorySnapshot, + PluginPackageRef, } from './types.ts' export type * from './types.ts' @@ -22,6 +26,15 @@ function pluginEntryId(value: string): PluginEntryId { return value as PluginEntryId } +/** The wire view of a row's package origin. */ +function packageRef(origin: { packageName: string; version?: string; originalId?: string }): PluginPackageRef { + return { + name: origin.packageName, + ...origin.version === undefined ? {} : { version: origin.version }, + ...origin.originalId === undefined ? {} : { originalId: origin.originalId }, + } +} + /** Runtime mirror: FiberState is a cross-package const enum. */ const FIBER_STATE = { PENDING: 0 as FiberState.PENDING, @@ -65,13 +78,42 @@ export class PluginInventoryGateway extends TypertRemoteService { @Remote('list') async list(): Promise { const entries: PluginInventoryEntry[] = [] + const runtime = this.ctx.get('profileRuntime') + const failures = this.ctx.get('pluginFailures') + const userDisabled = runtime?.userDisabledRowIds() ?? new Set() + const listed = new Set() for (const entry of this.ctx.loader.entries()) { if (entry.options.group) continue + listed.add(entry.id) + // Provenance and user patches address rows by the id the composition + // declares; the tree-wide `entry.id` adds the owning include's prefix. + const origin = runtime?.originOf(entry.options.id) + const enabled = !entry.disabled + const failure = failures?.get(entry.id) entries.push({ entryId: pluginEntryId(entry.id), moduleName: entry.options.name, - enabled: !entry.disabled, + enabled, fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + trust: origin?.trust ?? 'builtin', + ...origin === undefined ? {} : { package: packageRef(origin) }, + ...enabled ? {} : { disabledBy: userDisabled.has(entry.options.id) ? 'user' as const : 'composition' as const }, + ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } }, + }) + } + // A row an isolated bundle failed to start is gone from the tree; the + // registry is its only record, and the list must still show it. + for (const failure of failures?.list() ?? []) { + if (listed.has(failure.entryId)) continue + const origin = runtime?.originOf(failure.rowId) + entries.push({ + entryId: pluginEntryId(failure.entryId), + moduleName: failure.moduleName, + enabled: true, + fiberPhase: 'failed', + trust: origin?.trust ?? 'external', + ...origin === undefined ? {} : { package: packageRef(origin) }, + failure: { stage: failure.stage, message: failure.message }, }) } const presets = this.ctx.get('agentPresets') diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index 7012313292..3668c7cad1 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -12,6 +12,30 @@ export type PluginFiberPhase = | 'unloading' | null +/** Who supplied a Loader row: the installation itself or an installed external bundle. */ +export type PluginTrust = 'builtin' | 'external' + +/** Why a row is disabled: the composition's own gate or tombstone, or the user's patch layer. */ +export type PluginDisabledBy = 'composition' | 'user' + +/** The package a row belongs to, when a bundle layer inserted it. */ +export interface PluginPackageRef { + /** The bundle's package name. */ + readonly name: string + /** The package version, when its manifest declares one. */ + readonly version?: string + /** For an external row: the id the bundle's own patch declared, before prefixing. */ + readonly originalId?: string +} + +/** A recorded startup failure of a row inside an isolated external bundle. */ +export interface PluginFailure { + /** The lifecycle step that failed. */ + readonly stage: 'import' | 'apply' | 'inject-pending' | 'unknown' + /** The failure text. */ + readonly message: string +} + /** One non-group Loader entry exposed to trusted clients. */ export interface PluginInventoryEntry { readonly entryId: PluginEntryId @@ -20,6 +44,14 @@ export interface PluginInventoryEntry { /** Effective Loader enablement, including disabled ancestor groups. */ readonly enabled: boolean readonly fiberPhase: PluginFiberPhase + /** Who supplied the row; `builtin` when no profile runtime is composed. */ + readonly trust: PluginTrust + /** The bundle package that inserted the row, when one did. */ + readonly package?: PluginPackageRef + /** Present exactly when `enabled` is false. */ + readonly disabledBy?: PluginDisabledBy + /** Present for a row an isolated bundle failed to start. */ + readonly failure?: PluginFailure } /** Effective enablement of one preset composition row. */ diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 2bf3516db2..6211f1eb33 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -63,18 +63,22 @@ describe('PluginInventoryGateway', () => { moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', + trust: 'builtin', }, { entryId: pendingId, moduleName: 'cordis:pending', enabled: true, fiberPhase: 'pending', + trust: 'builtin', }, { entryId: disabledId, moduleName: 'cordis:not-installed', enabled: false, fiberPhase: null, + trust: 'builtin', + disabledBy: 'composition', }, ])) @@ -84,6 +88,8 @@ describe('PluginInventoryGateway', () => { moduleName: 'cordis:active', enabled: false, fiberPhase: null, + trust: 'builtin', + disabledBy: 'composition', }) await ctx.loader.remove(pendingId) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json index 15028f25f0..85b92a4cad 100644 --- a/packages/host/plugin-inventory/tsconfig.json +++ b/packages/host/plugin-inventory/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../preset/agent-presets" }, + { + "path": "../../boot/app-boot" + }, { "path": "../../util/brand" }, diff --git a/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts b/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts index 7db8b1fcc6..83965d3652 100644 --- a/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts +++ b/packages/test-support/loader-smoke/tests/fixtures/production-profile.ts @@ -43,8 +43,11 @@ function overlayModuleLayers(path: string, patches: readonly PatchOptions[]): Pr } return [...packages].map(([name, packageDir], index) => ({ packageName: `test-overlay:${index}:${name}`, + version: undefined, packageDir, patchPath: path, + trust: 'builtin' as const, + stage: 'runtime' as const, patches: [], })) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a305fb42a..bb5d543dc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5792,6 +5792,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index dd3d8e3e33..077bd779e9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -57,6 +57,7 @@ export const SERVICE_PAGE: Record = { agentLoop: 'core.md', agentDefaultModel: 'core.md', agentPresets: 'core.md', + profileRuntime: 'core.md', agents: 'core.md', approval: 'approval.md', attachments: 'attachment.md', @@ -151,6 +152,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', + pluginFailures: 'not a service: boot-provided contained-failure registry (ContainedFailureRegistry | undefined) the plugin inventory reads — packages/boot/app-boot/README.md owns the boot contract', launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', @@ -652,6 +654,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Promise', 'Record', 'Readonly', + 'Set', 'Uint8Array', ]) @@ -664,6 +667,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { InsertTextRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts', AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md', AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', + RowOrigin: 'row provenance record of the profile runtime is owned by packages/boot/app-boot/README.md', AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md', AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md', AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d8216809e6..c78688db82 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -377,6 +377,14 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.', }, + { + key: 'profileRuntime', + pkg: 'app-boot', + title: 'Booted profile facts and recomposition', + mode: 'core', + consumers: ['host-plugin-inventory'], + note: 'Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row\'s inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share.', + }, { key: 'commands', pkg: 'commands', From dc969e2ee857a777ca9a5c9aa2b3f2bdaea13e19 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 02:31:54 +0800 Subject: [PATCH 002/147] feat(plugins): plugin manager, preset user layers, and the patch-file writer Add `dsh-host-plugin-manager`: the `pluginManager` service and the `plugins` Remote that install, uninstall, enable, disable, and retry bundles over the booted profile, add and remove rows in the profile's or an agent preset's user layer, report what a package's services keep alive, and fold the manifest, the probe record, and the live tree into one view per package. pnpm runs the way the CLI runs it, streaming its output as `plugins/install-log`; every change emits `plugins/changed`, and api-remotes forwards both. Enabling is the Loader's own transaction: a rejected recomposition restores the layer list, and a runtime recomposition records contained rows through `recordContainedStates` because the boot audit does not run again; `ContainedGroup.create` now records a row that resolved pending rather than clearing it. Give every agent preset a user patch layer: `cordis.patch.yml` beside a locally authored composition, or alone in `.agent-presets//` for a shipped one. Discovery attaches and judges it, the mount applies it as the include's runtime patches, standing generations follow the layer's content and return to a retired generation when it comes back, the composition inventory reports each row's `source` and `disabledBy`, a copy carries the layer, and `removeOverlay` deletes it. `dsh-global-tool-mask` is `tools.restrict()` as a scope-only row for such a layer. Add `dsh-patch-file` as the one home of the patch-list format: the parser moved out of app-boot, plus a comment-preserving key-level writer with lock, atomic replace, and readback. app-boot exports `layerTrust`, `bundleLayerPatches`, and `recordContainedStates`, and `ProfileRuntime` carries the install anchor. --- ...manager-over-the-profile-runtime.i18n.yaml | 6 + ...plugin-manager-over-the-profile-runtime.md | 37 + ...gin-manager-over-the-profile-runtime.zh.md | 37 + ...atch-layer-and-patch-file-writer.i18n.yaml | 6 + ...-user-patch-layer-and-patch-file-writer.md | 35 + ...er-patch-layer-and-patch-file-writer.zh.md | 35 + apps/cli/package.json | 1 + apps/cli/src/profile-boot.ts | 16 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 7 +- docs/capability-seams.zh.md | 7 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 43 +- docs/config-catalog.zh.md | 43 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 + docs/event-producer-consumer.zh.md | 2 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 15 +- docs/module-graph.zh.md | 15 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 171 ++++ docs/subsystems/core.zh.md | 171 ++++ packages/api/remotes/package.json | 1 + packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/src/remote-events.ts | 3 + packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 8 +- packages/boot/app-boot/README.zh.md | 8 +- packages/boot/app-boot/package.json | 1 + packages/boot/app-boot/src/contained-group.ts | 36 +- .../boot/app-boot/src/external-bundles.ts | 12 + packages/boot/app-boot/src/index.ts | 124 ++- packages/boot/app-boot/src/profile-runtime.ts | 7 + packages/boot/app-boot/src/profile.ts | 22 +- .../app-boot/tests/contained-group.spec.ts | 24 + .../app-boot/tests/profile-runtime.spec.ts | 2 + packages/boot/app-boot/tsconfig.json | 3 + packages/bundle/web-app/cordis.patch.yml | 7 + packages/bundle/web-app/package.json | 1 + .../tests/components.client.spec.tsx | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 181 +++- .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/plugin-inventory/src/types.ts | 4 + packages/host/plugin-manager/README.i18n.yaml | 6 + packages/host/plugin-manager/README.md | 132 +++ packages/host/plugin-manager/README.zh.md | 132 +++ packages/host/plugin-manager/package.json | 75 ++ packages/host/plugin-manager/src/index.ts | 858 ++++++++++++++++++ packages/host/plugin-manager/src/types.ts | 222 +++++ .../tests/plugin-manager.spec.ts | 754 +++++++++++++++ packages/host/plugin-manager/tsconfig.json | 39 + .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 28 +- packages/preset/agent-presets/README.zh.md | 28 +- packages/preset/agent-presets/package.json | 2 + .../preset/agent-presets/src/authoring.ts | 35 + .../src/composition-inventory.ts | 94 +- .../preset/agent-presets/src/discovery.ts | 168 +++- packages/preset/agent-presets/src/index.ts | 170 +++- packages/preset/agent-presets/src/mount.ts | 20 +- packages/preset/agent-presets/src/preset.ts | 8 + .../tests/composition-inventory.spec.ts | 42 +- .../preset/agent-presets/tests/mount.spec.ts | 4 +- .../agent-presets/tests/overlay.spec.ts | 342 +++++++ packages/preset/agent-presets/tsconfig.json | 3 + .../preset/global-tool-mask/README.i18n.yaml | 6 + packages/preset/global-tool-mask/README.md | 98 ++ packages/preset/global-tool-mask/README.zh.md | 98 ++ packages/preset/global-tool-mask/package.json | 42 + packages/preset/global-tool-mask/src/index.ts | 54 ++ .../tests/global-tool-mask.spec.ts | 71 ++ .../preset/global-tool-mask/tsconfig.json | 22 + packages/util/patch-file/README.i18n.yaml | 6 + packages/util/patch-file/README.md | 112 +++ packages/util/patch-file/README.zh.md | 112 +++ packages/util/patch-file/package.json | 45 + packages/util/patch-file/src/index.ts | 397 ++++++++ .../util/patch-file/tests/patch-file.spec.ts | 251 +++++ packages/util/patch-file/tsconfig.json | 24 + pnpm-lock.yaml | 96 ++ scripts/gen-cordis-catalog.ts | 10 + scripts/gen-doc-graphs.ts | 9 +- .../verify-package-readme-model-experience.ts | 3 + tsconfig.base.json | 4 + tsconfig.host.json | 3 + 93 files changed, 5535 insertions(+), 240 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md create mode 100644 packages/host/plugin-manager/README.i18n.yaml create mode 100644 packages/host/plugin-manager/README.md create mode 100644 packages/host/plugin-manager/README.zh.md create mode 100644 packages/host/plugin-manager/package.json create mode 100644 packages/host/plugin-manager/src/index.ts create mode 100644 packages/host/plugin-manager/src/types.ts create mode 100644 packages/host/plugin-manager/tests/plugin-manager.spec.ts create mode 100644 packages/host/plugin-manager/tsconfig.json create mode 100644 packages/preset/agent-presets/tests/overlay.spec.ts create mode 100644 packages/preset/global-tool-mask/README.i18n.yaml create mode 100644 packages/preset/global-tool-mask/README.md create mode 100644 packages/preset/global-tool-mask/README.zh.md create mode 100644 packages/preset/global-tool-mask/package.json create mode 100644 packages/preset/global-tool-mask/src/index.ts create mode 100644 packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts create mode 100644 packages/preset/global-tool-mask/tsconfig.json create mode 100644 packages/util/patch-file/README.i18n.yaml create mode 100644 packages/util/patch-file/README.md create mode 100644 packages/util/patch-file/README.zh.md create mode 100644 packages/util/patch-file/package.json create mode 100644 packages/util/patch-file/src/index.ts create mode 100644 packages/util/patch-file/tests/patch-file.spec.ts create mode 100644 packages/util/patch-file/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml new file mode 100644 index 0000000000..5014e8a675 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-04-plugin-manager-over-the-profile-runtime.md +2026-09-04-plugin-manager-over-the-profile-runtime.md: 8b12c1dcbee5e5f463fcba181441717de5b20c39 +2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 2f1fa6682124677c0614f24ef005f3efc212bd57 diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md new file mode 100644 index 0000000000..8b12c1dcbe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md @@ -0,0 +1,37 @@ +# Agent Note: The plugin manager drives the profile runtime + +Status: implemented + +English | [中文](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md) + +## Problem + +Installing a plugin was a terminal-only act: `dsh plugin --profile web add ` ran pnpm, appended every bundle it found to `dsh.profile.bundles`, and the next start composed it. Nothing running could learn what was installed but not enabled, switch a bundle off without editing `package.json` by hand, add one package's module to the profile's user layer or to one agent preset, or say which rows a package's service kept alive. The Web surface could list rows through `pluginInventory/list` and nothing more, while the launcher's `profileRuntime` (previous note) already recomposed the tree on a user patch reload and `reconcileInstalledBundles` already separated installation from enablement. The missing piece was the host service that performs the operations and reports each package as one thing. + +## Decision + +**One service, one manifest.** `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote: `list`, `install`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents`. Every operation reads the profile manifest afresh and writes it through the same app-boot helpers the CLI uses — `reconcileInstalledBundles`, `enableBundle`, `disableBundle` — so the CLI and the manager cannot disagree on the file: `dependencies` says what is installed, `dsh.profile.bundles` says what is enabled. The profile runtime is resolved per call rather than injected, so the web bundle's row starts in a composition booted without the profile launcher and answers `plugins/unavailable`. + +**Enablement is the Loader's transaction.** `enable` puts the bundle in the layer list and calls `profileRuntime.recompose({ reloadBundles: true })`, after `healProfilesModuleFallback` has linked the packages the bundle carries. A rejected recomposition — a `boot`-stage bundle whose row throws — is the Loader rolling back to the tree that was running; the manager restores the list and reports `plugins/enable-failed`. A `runtime`-stage bundle whose row fails is isolated by the contained group and reported per row. Because the boot audit does not run again, the manager calls `recordContainedStates` after a live recomposition, and `ContainedGroup.create` now records a row that resolved in the pending state instead of clearing it — a reload re-creates every row of a group, and a waiting row must keep its record through that. `retry` is disable then enable: the Loader's update leaves an unchanged row alone, so only leaving and returning restarts a failed isolated row. + +**pnpm runs the way the CLI runs it.** Through `node:child_process` with the parent environment and `shell` on Windows, not through the subprocess seam: the seam scrubs secret-shaped variables pnpm needs for registries and proxies and has no shell mode for the `.cmd` shim. Output streams as `plugins/install-log` chunks under a job id; a non-zero exit, a spawn error, or the timeout is `plugins/install-failed` with the log tail. New packages are probed and left disabled unless the caller asked for `enable`. + +**Rows go through the patch-file writer.** `addRow` inserts `{ id, name, config }` into the profile's `cordis.patch.yml` or an agent preset's user layer (through the roster's `overlayPathFor`), with the id derived from the package name and subpath; `setRowDisabled` is deny-only, writing or removing `disabled: true` so a bundle's `!!js` gate is restored rather than overridden. The global layer is recomposed on the spot; a preset's layer reaches its next standing generation. + +**One view per package.** `list` folds the manifest, the probe record (cached under `.dsh-plugins/`, refreshed on a version change), and the live tree into a `status`: `running`, `partial`, or `failed` by active rows; `disabled`; `not-enableable` with the probe's reason; `restart-required` when a `startup`-reload profile's manifest and tree disagree; `plain` for a library or plugin module. Rows come from the tree while composed and from the probe otherwise, already carrying the prefixed ids the launcher will use; trust for a bundle outside the tree comes from `layerTrust`, the one rule `loadProfile` also applies. + +## Alternatives considered + +**Running pnpm through `ctx.subprocess`.** Rejected for this release: the seam has no shell mode and scrubs the environment; adding both to the seam for one caller is a larger change than the manager, and the CLI's spawn is proven. + +**Restarting a failed row in place on `retry`.** Rejected: a row's options are unchanged, so the Loader's transactional update would not touch it; re-creating one row by hand would bypass the group's own create path and its failure record. + +**Enabling a bundle without probing it.** Rejected: the probe is what turns a package that cannot import, or that resolves its own cordis copy, into a `not-enableable` view with a reason before the tree is asked to mount it. + +## Consequences + +A running Web host can install, enable, disable, retry, and remove third-party bundles and add their modules to the global layer or a preset without a restart on a live profile. Updating a loaded package still needs a restart (Node's module cache); `dependents` stops at injection edges; `engines.dsh` is reported, not enforced; the client UI arrives in a later PR. + +## Testing + +`packages/host/plugin-manager/tests/plugin-manager.spec.ts` boots a temporary profile through `boot()` with the profile runtime the launcher provides and a fake pnpm that edits the manifest the way the real one does: the view fold (installed, enabled, probed, waiting, user-disabled, first-party, hand-written manifests), install with and without enabling and its failures (exit code, spawn error, timeout, log tail), enable and disable live and on a `startup` profile, the boot-stage rollback, retry of a flaky isolated row, rows in the global and a preset's layer with conflicts, dependents by provided service and by user-layer reference, and uninstall. `packages/boot/app-boot/tests/contained-group.spec.ts` pins the waiting-row record across a reload. diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md new file mode 100644 index 0000000000..2f1fa66821 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md @@ -0,0 +1,37 @@ +# Agent Note:插件管理器驱动 profile runtime + +Status: implemented + +[English](2026-09-04-plugin-manager-over-the-profile-runtime.md) | 中文 + +## 问题 + +安装插件曾是只有终端能做的事:`dsh plugin --profile web add ` 运行 pnpm,把找到的每个组合包追加进 `dsh.profile.bundles`,下次启动再组合。运行中的任何东西都无法得知哪些包装了但没启用,无法不手改 `package.json` 就关掉一个组合包,无法把某个包的模块加进 profile 的用户层或某个 agent preset,也说不出一个包的服务撑着哪些行。Web 界面能经 `pluginInventory/list` 列出行,仅此而已;而 launcher 的 `profileRuntime`(前一篇笔记)已经能在用户 patch 重载时重新组合树,`reconcileInstalledBundles` 也已经把安装与启用分开。缺的是执行这些操作并把每个包报告成一个整体的宿主服务。 + +## 决定 + +**一个服务,一份 manifest。** `dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote:`list`、`install`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。 + +**启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。 + +**pnpm 按 CLI 的方式运行。** 经 `node:child_process`、带父进程环境、Windows 上开 `shell`,而不经 subprocess seam:seam 会清洗 pnpm 访问 registry 与代理所需的形似密钥的变量,也没有解析 `.cmd` shim 的 shell 模式。输出以某个 job id 下的 `plugins/install-log` 分块流式发出;非零退出、spawn 错误或超时即带日志尾部的 `plugins/install-failed`。新包被探测并保持停用,除非调用方要求 `enable`。 + +**行经补丁文件写入器落地。** `addRow` 把 `{ id, name, config }` 插入 profile 的 `cordis.patch.yml` 或某个 agent preset 的用户层(经 roster 的 `overlayPathFor`),id 由包名与子路径派生;`setRowDisabled` 只写拒绝,写入或移除 `disabled: true`,因此组合包的 `!!js` 门被恢复而不是被覆盖。全局层当场重新组合;preset 的层在其下一个常驻代际生效。 + +**每个包一份视图。** `list` 把 manifest、探针记录(缓存在 `.dsh-plugins/` 下,版本变化即刷新)与在线树折叠成一个 `status`:按活跃行数是 `running`、`partial` 或 `failed`;`disabled`;带探针原因的 `not-enableable`;`startup` 重载的 profile 上 manifest 与树不一致时是 `restart-required`;库或插件模块是 `plain`。行在已组合时来自树,否则来自探针,并已带上 launcher 将使用的前缀 id;树外组合包的 trust 来自 `layerTrust`,这也是 `loadProfile` 所用的同一条规则。 + +## 考虑过的替代方案 + +**经 `ctx.subprocess` 运行 pnpm。** 本次否决:seam 没有 shell 模式且会清洗环境;为一个调用方给 seam 加上两者,比管理器本身改动更大,而 CLI 的 spawn 已被验证。 + +**`retry` 时原地重启失败的行。** 否决:行的 options 未变,Loader 的事务性更新不会碰它;手工重建一行会绕过组自己的创建路径及其失败记录。 + +**不探测就启用组合包。** 否决:正是探针把无法 import、或解析到自己那份 cordis 副本的包,在树被要求挂载之前变成带原因的 `not-enableable` 视图。 + +## 后果 + +运行中的 Web 宿主可以在 live profile 上不重启地安装、启用、停用、重试与移除三方组合包,并把它们的模块加进全局层或某个 preset。更新已加载的包仍需重启(Node 的模块缓存);`dependents` 止于注入边;`engines.dsh` 只报告不强制;客户端 UI 在后续 PR 到来。 + +## 测试 + +`packages/host/plugin-manager/tests/plugin-manager.spec.ts` 经 `boot()` 启动一个临时 profile,带上 launcher 提供的 profile runtime 与一个按真实 pnpm 的方式编辑 manifest 的假 pnpm:视图折叠(已安装、已启用、已探测、等待中、用户停用、一方包、手写 manifest),带与不带启用的安装及其失败(退出码、spawn 错误、超时、日志尾部),live 与 `startup` profile 上的启用与停用,boot 阶段的回滚,不稳定隔离行的重试,全局层与 preset 层里的行及其冲突,按提供服务与按用户层引用的依赖检测,以及卸载。`packages/boot/app-boot/tests/contained-group.spec.ts` 钉住等待中的行记录跨重载保留。 diff --git a/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.i18n.yaml new file mode 100644 index 0000000000..f550d46358 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-04-preset-user-patch-layer-and-patch-file-writer.md +2026-09-04-preset-user-patch-layer-and-patch-file-writer.md: 6610e28584b57a91443b1bde2e7d81b345ef853d +2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md: 62a92eb366263407b8122bda29a2caaad65fd706 diff --git a/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.md b/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.md new file mode 100644 index 0000000000..6610e28584 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.md @@ -0,0 +1,35 @@ +# Agent Note: A preset takes a user patch layer, and patch files get one home + +Status: implemented + +English | [中文](2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md) + +## Problem + +A shipped agent preset was read-only in every sense: to switch one of its rows off or add a tool to it, a person copied the whole preset and edited the copy, which then drifted from the shipped one on every upgrade. The profile already had the shape that solves this — a base composition plus a `cordis.patch.yml` user layer applied over it — and the preset had nothing of the kind. Separately, that file format had one parser in `dsh-app-boot` and no writer: a program that wanted to switch a row off had to rewrite the whole file, losing the author's comments and any `!!js` gate it did not understand. + +## Decision + +**A preset's user layer is `cordis.patch.yml` in its slot.** Beside the composition for a locally authored preset; alone in the user root's directory of the same id (`$DSH_HOME/.agent-presets//cordis.patch.yml`) for a shipped one, whose install stays untouched. Discovery attaches the layer to the preset that wins the id, judges it with the composition (an unparsable layer, a malformed insert, or an inserted row naming a module that cannot resolve makes the preset broken with that reason), and reports a layer whose id no root supplies as a broken slot rather than hiding it. The mount hands the parsed layer to the include as its runtime patches, so it applies with the Loader's own patch semantics. The composition inventory applies it before flattening and marks each row `source: 'preset' | 'user'` and, when off, `disabledBy: 'composition' | 'user'`. A copy carries the layer in beside the new composition; `removeOverlay` deletes it and an emptied slot. + +**Generations follow the layer's content.** The standing mount's stamp now covers the composition file's stat and a digest of the layer's text. A changed layer starts the next generation for sessions created afterwards, as an edited composition always did; a layer edited back to a content an earlier generation composed returns to that generation, so switching a row off and on again does not stack a third live subtree. + +**`dsh-patch-file` owns the format.** `parsePatchList` moved out of `dsh-app-boot` and is the one parser (js-yaml with the include's `!!js` dialect, relative names anchored to the file); `PatchDocument` edits a file at the key level through the `yaml` package's comment-preserving document, which keeps an unresolved `!!js` tag on its scalar and prints it back verbatim; `mutatePatchFile` takes the `dsh-atomic-write` lock, reads, edits, replaces atomically when the document is dirty, and parses the written text back. A removed row's comment block moves to its neighbour or the document's trailing comment instead of vanishing. + +**`dsh-global-tool-mask` is `tools.restrict()` as a row.** A preset's layer can add rows but could not subtract a host tool; this scope-only row masks named global tools for the sessions of one preset and rejects an unscoped mount, an empty mask, and a name the host does not register. + +## Alternatives considered + +**A settings namespace per preset for enablement.** Rejected earlier in the design: enablement is composition, not preference, and the profile's own user layer is already a patch file; a preset's should be the same file in the same format. + +**Editing the shipped composition in place.** Rejected: an upgrade overwrites it, and a session already running would be the one reading a half-written file. + +**A second parser in `dsh-agent-presets` to avoid a dependency on `dsh-app-boot`.** Rejected: two parsers of one format drift; the format got its own package instead, which both depend on. + +## Consequences + +A person hides a tool from `standard` with three lines under `.agent-presets/standard/` and keeps the shipped composition; the plugin manager writes the same file for them. A layer applies to the sessions created after it changes, never to running ones. The layer addresses rows by the composition's own ids, so an id the composition leaves anonymous cannot be targeted. + +## Testing + +`packages/preset/agent-presets/tests/overlay.spec.ts` pins discovery (attach, own layer, orphan slot, unparsable, malformed, unresolvable insert, composition verdict first), the mount applying the layer, generations across edits with the retired generation reused, the inventory's `source` and `disabledBy` from the file and from a mount, the layer path for shipped and authored presets, copy carrying the layer, and removal. `packages/util/patch-file/tests/patch-file.spec.ts` pins the parser, the document edits with comments and `!!js` intact, and the locked atomic mutation with readback. `packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts` pins the row's scoping and refusals. diff --git a/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md b/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md new file mode 100644 index 0000000000..62a92eb366 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md @@ -0,0 +1,35 @@ +# Agent Note:preset 获得用户补丁层,补丁文件有了唯一的家 + +Status: implemented + +[English](2026-09-04-preset-user-patch-layer-and-patch-file-writer.md) | 中文 + +## 问题 + +随附的 agent preset 在任何意义上都是只读的:想关掉它的一行或给它加一个工具,只能整个复制 preset 再改副本,而副本从此在每次升级时与随附版本渐行渐远。profile 早已具备解决这件事的形状——一份基底组合加一层施加其上的 `cordis.patch.yml` 用户层——而 preset 没有任何对应物。另一方面,这种文件格式在 `dsh-app-boot` 里只有一个解析器而没有写入器:想关掉一行的程序只能整个改写文件,丢掉作者的注释和它看不懂的 `!!js` 门。 + +## 决定 + +**preset 的用户层就是其槽位里的 `cordis.patch.yml`。** 本地创作的 preset 放在组合旁边;随附 preset 则单独放在用户根目录里同 id 的目录(`$DSH_HOME/.agent-presets//cordis.patch.yml`),其安装目录不动。discovery 把这一层挂到赢得该 id 的 preset 上,与组合一起判定健康(不可解析的层、畸形的 insert、或插入了无法解析模块的行,都以该原因让 preset 变为 broken),并把没有任何根提供其 id 的层报告为损坏的槽位而不是藏起来。mount 把解析后的层作为 include 的运行时 patches 交上去,于是它以 Loader 自己的补丁语义施加。组合清单在展平前先施加它,并给每一行标上 `source: 'preset' | 'user'`,关闭时再标上 `disabledBy: 'composition' | 'user'`。复制会把该层带到新组合旁边;`removeOverlay` 删除它以及被清空的槽位。 + +**代际跟随层的内容。** 常驻挂载的印记现在覆盖组合文件的 stat 与层文本的摘要。层变化即为之后创建的会话开启下一代,一如组合被编辑时的既有行为;层被改回某个早先代际组合过的内容时,回到那一代,因此一行关掉再打开不会堆出第三棵在线子树。 + +**`dsh-patch-file` 拥有这种格式。** `parsePatchList` 从 `dsh-app-boot` 搬出,成为唯一的解析器(js-yaml 配 include 的 `!!js` 方言,相对名字锚定到文件);`PatchDocument` 通过 `yaml` 包保留注释的 document 在键级编辑文件,它把未解析的 `!!js` 标签留在标量上并原样打印;`mutatePatchFile` 拿 `dsh-atomic-write` 的锁,读取、编辑、在 document 变脏时原子替换,并把写出的文本回读解析。被移除行上方的注释块移到邻居或文档尾注释,而不是消失。 + +**`dsh-global-tool-mask` 是写成行的 `tools.restrict()`。** preset 的层能加行却减不掉宿主工具;这一仅限作用域的行对某个 preset 的会话遮蔽点名的全局工具,并拒绝无作用域挂载、空掩码与宿主未注册的名字。 + +## 考虑过的替代方案 + +**为每个 preset 用一个 settings 命名空间做启停。** 在设计阶段已否决:启停是组合而非偏好,profile 自己的用户层本就是补丁文件;preset 的应当是同样格式的同一种文件。 + +**原地编辑随附组合。** 否决:升级会覆盖它,而正在运行的会话会读到写了一半的文件。 + +**在 `dsh-agent-presets` 里再写一个解析器以避免依赖 `dsh-app-boot`。** 否决:同一格式的两个解析器会漂移;改为让格式拥有自己的包,两者都依赖它。 + +## 后果 + +一个人用 `.agent-presets/standard/` 下的三行就能对 `standard` 隐藏一个工具并保留随附组合;插件管理器替他们写同一个文件。层作用于其变化之后创建的会话,从不作用于运行中的会话。层按组合自己的 id 寻址行,因此组合留作匿名的行无法被定位。 + +## 测试 + +`packages/preset/agent-presets/tests/overlay.spec.ts` 钉住 discovery(附着、自有层、孤儿槽位、不可解析、畸形、不可解析的插入、组合判定优先)、施加层的挂载、编辑之间的代际及退役代际的复用、清单从文件与从挂载得到的 `source` 与 `disabledBy`、随附与自作 preset 的层路径、复制携带层,以及移除。`packages/util/patch-file/tests/patch-file.spec.ts` 钉住解析器、保留注释与 `!!js` 的文档编辑,以及带回读的加锁原子变更。`packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts` 钉住该行的作用域与拒绝。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 7a389053d4..af034ea7a7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-global-tool-mask": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 9b9c48683a..9602799225 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -19,8 +19,8 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, + bundleLayerPatches, composeEntries, - composeExternalLayer, healProfilesModuleFallback, installFailLoud, installRuntimeGuards, @@ -33,7 +33,6 @@ import { warnNestedFiberFailures, watchUserPatches, type Profile, - type ProfileLayer, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' @@ -149,18 +148,6 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { ] } -/** - * The patches one bundle layer contributes. A built-in layer, or an external - * layer the profile stages at boot, mounts its patches as written; every other - * external layer mounts as one contained, id-prefixed group. - * @param layer - the resolved layer. - * @returns the layer's patches in application order. - */ -export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] { - if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches - return layer.patches -} - /** * Load `name` and compose its effective patch stack: bundle layers in * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's @@ -306,6 +293,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) }) await ctx.plugin(ProfileRuntime, { profile: composed.profile, + installAnchor: INSTALL_ANCHOR, loadProfile: () => prepareProfile(options.profile), compose: composeFor, rootEntry: () => rootIncludeEntry(ctx), diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ff2fbbfca9..5defa9eea4 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: d054e99bd988f1555985f80ddc6ee3f3011251d6 -architecture.zh.md: 246546518fea0fbde58f95e09802b814daab4b30 +architecture.md: fb71638bb8583978c4bca5c9751f8435d04f9e7a +architecture.zh.md: 7a0c902e90771e8d399868ceda9798d92651f92b diff --git a/docs/architecture.md b/docs/architecture.md index d054e99bd9..fb71638bb8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows. -A bundle installed into the profile with `dsh plugin` is external. Its rows mount under one contained group, `bundle/`, with ids prefixed `/`, and a row that fails to start is isolated and reported in the plugin list while built-in rows keep failing the boot. A bundle that provides a service built-in rows inject opts out of isolation with `dsh.bundle.stage: boot`, and an isolated failure that leaves a built-in row waiting still stops the boot and names the bundle. Installing a package and enabling its layer are separate facts: `dependencies` records the install, `dsh.profile.bundles` records the enabled layers. +A bundle installed into the profile with `dsh plugin` is external. Its rows mount under one contained group, `bundle/`, with ids prefixed `/`, and a row that fails to start is isolated and reported in the plugin list while built-in rows keep failing the boot. A bundle that provides a service built-in rows inject opts out of isolation with `dsh.bundle.stage: boot`, and an isolated failure that leaves a built-in row waiting still stops the boot and names the bundle. Installing a package and enabling its layer are separate facts: `dependencies` records the install, `dsh.profile.bundles` records the enabled layers. The `plugins` Remote (`dsh-host-plugin-manager`) performs both at runtime through `profileRuntime`, and adds rows to the profile's user layer or to an agent preset's user layer (`.agent-presets//cordis.patch.yml`), which every preset accepts over its composition. Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 246546518f..7a0c902e90 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -26,7 +26,7 @@ 各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。 -用 `dsh plugin` 装进 profile 的组合包是外部的。它的行挂在一个受控组 `bundle/` 下,id 带 `/` 前缀;启动失败的行被隔离并在插件列表里报告,而内置行仍然让启动失败。提供内置行所注入服务的组合包用 `dsh.bundle.stage: boot` 退出隔离;隔离的失败若让某个内置行停在等待状态,启动仍会失败并点名该组合包。安装一个包和启用它的层是两件事:`dependencies` 记录安装,`dsh.profile.bundles` 记录已启用的层。 +用 `dsh plugin` 装进 profile 的组合包是外部的。它的行挂在一个受控组 `bundle/` 下,id 带 `/` 前缀;启动失败的行被隔离并在插件列表里报告,而内置行仍然让启动失败。提供内置行所注入服务的组合包用 `dsh.bundle.stage: boot` 退出隔离;隔离的失败若让某个内置行停在等待状态,启动仍会失败并点名该组合包。安装一个包和启用它的层是两件事:`dependencies` 记录安装,`dsh.profile.bundles` 记录已启用的层。`plugins` Remote(`dsh-host-plugin-manager`)在运行时经 `profileRuntime` 完成这两件事,并向 profile 的用户层或某个 agent preset 的用户层(`.agent-presets//cordis.patch.yml`)添加行;每个 preset 都接受这样一层施加在其组合之上。 自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index c969bb9c9f..37ccda00a8 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: 409315b7f15a124511db1baf9e28ed440006ad72 -capability-seams.zh.md: 33338ea631df091a16c7a5fea79a6cb12d5af092 +capability-seams.md: f6ba5a55f0c6dc9dd1571f23af51f6bee4af6d8a +capability-seams.zh.md: ac43955e9fcff59438d4b602c2e45750dba8ad98 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 409315b7f1..f6ba5a55f0 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -106,6 +106,8 @@ flowchart LR svc_planMode["ctx.planMode
Plan collaboration state"] pkg_agent_presets["agent-presets"] svc_agentPresets["ctx.agentPresets
Per-session agent composition"] + pkg_host_plugin_manager["host-plugin-manager"] + svc_pluginManager["ctx.pluginManager
Plugin management over the booted profile"] pkg_app_boot["app-boot"] svc_profileRuntime["ctx.profileRuntime
Booted profile facts and recomposition"] pkg_host_plugin_inventory["host-plugin-inventory"] @@ -264,6 +266,7 @@ flowchart LR pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker + pkg_host_plugin_manager --> svc_pluginManager pkg_host_webserver --> svc_webServer pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants @@ -381,6 +384,7 @@ flowchart LR svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp svc_profileRuntime --> pkg_host_plugin_inventory + svc_profileRuntime --> pkg_host_plugin_manager svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -505,7 +509,8 @@ flowchart LR | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | -| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. | +| `ctx.pluginManager` | `core` | [`host-plugin-manager`](../packages/host/plugin-manager) | - | - | - | Installs, enables, disables, retries, and removes bundles through pnpm and the profile runtime, edits rows in the profile's or a preset's user layer, and folds manifest, probe, and tree facts into one view per package for the plugins Remote. | +| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 33338ea631..ac43955e9f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -108,6 +108,8 @@ flowchart LR svc_planMode["ctx.planMode
Plan collaboration state"] pkg_agent_presets["agent-presets"] svc_agentPresets["ctx.agentPresets
Per-session agent composition"] + pkg_host_plugin_manager["host-plugin-manager"] + svc_pluginManager["ctx.pluginManager
Plugin management over the booted profile"] pkg_app_boot["app-boot"] svc_profileRuntime["ctx.profileRuntime
Booted profile facts and recomposition"] pkg_host_plugin_inventory["host-plugin-inventory"] @@ -266,6 +268,7 @@ flowchart LR pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker + pkg_host_plugin_manager --> svc_pluginManager pkg_host_webserver --> svc_webServer pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants @@ -383,6 +386,7 @@ flowchart LR svc_llm --> pkg_compaction_basic svc_lsp --> pkg_tool_lsp svc_profileRuntime --> pkg_host_plugin_inventory + svc_profileRuntime --> pkg_host_plugin_manager svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -507,7 +511,8 @@ flowchart LR | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | -| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | 由 profile launcher 在启动后提供:已组合的组合包层及其 trust 与 stage、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 | +| `ctx.pluginManager` | `core` | [`host-plugin-manager`](../packages/host/plugin-manager) | - | - | - | 经 pnpm 与 profile runtime 安装、启用、停用、重试与移除组合包,编辑 profile 或某个 preset 用户层里的行,并把 manifest、探针与树的事实折叠成每个包一份视图供 plugins Remote 使用。 | +| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | 由 profile launcher 在启动后提供:已组合的组合包层及其 trust 与 stage、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f9c1c71c3b..45d54bd0f4 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: cd84522a925025686607b3b8f252d0474e357fa7 -config-catalog.zh.md: d23b8471e9085bc1128f1dc32937a9070802506f +config-catalog.md: 17a9aafed2def073be11f738c1b02e9cde6aaf32 +config-catalog.zh.md: cb531c531d6fb80227af497a0168ca638c17faa8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cd84522a92..17a9aafed2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -157,7 +157,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) +Source: [`packages/preset/agent-presets/src/preset.ts:60`](../packages/preset/agent-presets/src/preset.ts) @@ -747,6 +747,24 @@ Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local) Source: [`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts) + + +## `@deepseek-ai/dsh-global-tool-mask` + +Requires: `tools` + +```ts config-catalog +/** Plugin config: the global-tool mask, in `tools.restrict()`'s own vocabulary. */ +export interface Config { + /** Global tool names that stay visible; everything else is hidden. Empty means no allow list. */ + allow: string[] + /** Global tool names hidden from this scope. Empty means no deny list. */ + deny: string[] +} +``` + +Source: [`packages/preset/global-tool-mask/src/index.ts:28`](../packages/preset/global-tool-mask/src/index.ts) + ## `@deepseek-ai/dsh-goal` @@ -874,6 +892,28 @@ export interface Config { Source: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts) + + +## `@deepseek-ai/dsh-host-plugin-manager` + +Requires: `loader` + +```ts config-catalog +/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */ +export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand: string + /** Bound on one install or remove run, in milliseconds. */ + installTimeoutMs: number + /** Bound on one package probe, in milliseconds. */ + probeTimeoutMs: number + /** How many trailing bytes of an install run's output an install failure reports. */ + installLogTailBytes: number +} +``` + +Source: [`packages/host/plugin-manager/src/index.ts:96`](../packages/host/plugin-manager/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` @@ -3448,6 +3488,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-loader-smoke` ([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts)) +- `@deepseek-ai/dsh-patch-file` ([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts)) - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d23b8471e9..cb531c531d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -159,7 +159,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) +来源:[`packages/preset/agent-presets/src/preset.ts:60`](../packages/preset/agent-presets/src/preset.ts) @@ -749,6 +749,24 @@ export type Config = LocalConfig 来源:[`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts) + + +## `@deepseek-ai/dsh-global-tool-mask` + +需要:`tools` + +```ts config-catalog +/** Plugin config: the global-tool mask, in `tools.restrict()`'s own vocabulary. */ +export interface Config { + /** Global tool names that stay visible; everything else is hidden. Empty means no allow list. */ + allow: string[] + /** Global tool names hidden from this scope. Empty means no deny list. */ + deny: string[] +} +``` + +来源:[`packages/preset/global-tool-mask/src/index.ts:28`](../packages/preset/global-tool-mask/src/index.ts) + ## `@deepseek-ai/dsh-goal` @@ -876,6 +894,28 @@ export interface Config { 来源:[`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts) + + +## `@deepseek-ai/dsh-host-plugin-manager` + +需要:`loader` + +```ts config-catalog +/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */ +export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand: string + /** Bound on one install or remove run, in milliseconds. */ + installTimeoutMs: number + /** Bound on one package probe, in milliseconds. */ + probeTimeoutMs: number + /** How many trailing bytes of an install run's output an install failure reports. */ + installLogTailBytes: number +} +``` + +来源:[`packages/host/plugin-manager/src/index.ts:96`](../packages/host/plugin-manager/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` @@ -3449,6 +3489,7 @@ export interface Config { - `@deepseek-ai/dsh-loader-smoke`([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts)) +- `@deepseek-ai/dsh-patch-file`([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/src/index.ts)) - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 971dc86cb3..3d41ee4c6b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 62eba828604ba1e90d9d7ef22743cf10daf84f70 -event-producer-consumer.zh.md: f82c379516d9aec00dade2acdf3f8291a340da44 +event-producer-consumer.md: 47aa3f6588c61b5843f1fd8c871192adc09efe28 +event-producer-consumer.zh.md: f83a5ea9f68276c3fa515e1bcb16287f7cddca91 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 62eba82860..47aa3f6588 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -45,6 +45,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `plugins/changed` | `emit` | [`packages/host/plugin-manager/src/types.ts:212`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` | +| `plugins/install-log` | `emit` | [`packages/host/plugin-manager/src/types.ts:218`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f82c379516..f83a5ea9f6 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -47,6 +47,8 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `plugins/changed` | `emit` | [`packages/host/plugin-manager/src/types.ts:212`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` | +| `plugins/install-log` | `emit` | [`packages/host/plugin-manager/src/types.ts:218`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6a83eb36c2..9509b44eb7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 7dfe9a3b312c141e4df4c74f96a78deeade9b718 -module-graph.zh.md: 4e70a6a9baeda7d379672d4aa102806f9e816d3e +module-graph.md: 18a54edf93d052e87a7e0d5f7430cebc902f5c3a +module-graph.zh.md: ff41b5e11862b75eb075d3752b59c7eef6909e07 diff --git a/docs/module-graph.md b/docs/module-graph.md index 7dfe9a3b31..18a54edf93 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -16,6 +16,7 @@ flowchart TD pkg_launch_environment["launch-environment"] pkg_native_command["native-command"] pkg_output_retention["output-retention"] + pkg_patch_file["patch-file"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] pkg_util_time["util-time"] @@ -236,6 +237,7 @@ flowchart TD pkg_host_directory_picker_native["host-directory-picker-native"] pkg_host_frontend_static["host-frontend-static"] pkg_host_plugin_inventory["host-plugin-inventory"] + pkg_host_plugin_manager["host-plugin-manager"] pkg_host_webserver["host-webserver"] end subgraph group_identity["packages/identity"] @@ -263,6 +265,7 @@ flowchart TD end subgraph group_preset["packages/preset"] pkg_agent_presets["agent-presets"] + pkg_global_tool_mask["global-tool-mask"] pkg_persona["persona"] end subgraph group_runtime_diagnostics["packages/runtime-diagnostics"] @@ -813,6 +816,7 @@ flowchart TD pkg_agent_presets --> pkg_atomic_write pkg_agent_presets --> pkg_home_paths pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_patch_file pkg_agent_presets --> pkg_scope pkg_agent_presets --> pkg_session pkg_agent_presets --> pkg_session_projection @@ -820,6 +824,7 @@ flowchart TD pkg_agent_presets --> pkg_system_prompt pkg_agent_presets --> pkg_tools pkg_agent_presets --> pkg_typert_protocol + pkg_global_tool_mask --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -923,6 +928,11 @@ flowchart TD pkg_host_plugin_inventory --> pkg_app_boot pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol + pkg_host_plugin_manager --> pkg_agent_presets + pkg_host_plugin_manager --> pkg_app_boot + pkg_host_plugin_manager --> pkg_patch_file + pkg_host_plugin_manager --> pkg_typert_protocol + pkg_host_plugin_manager --> pkg_util_values pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm @@ -1159,6 +1169,7 @@ flowchart TD | [`launch-environment`](../packages/util/launch-environment) | `util` | — | | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | +| [`patch-file`](../packages/util/patch-file) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`util-crypto`](../packages/util/crypto) | `util` | — | | [`util-time`](../packages/util/time) | `util` | — | @@ -1355,7 +1366,8 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`patch-file`](../packages/util/patch-file), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`global-tool-mask`](../packages/preset/global-tool-mask) | `preset` | [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1376,6 +1388,7 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 4e70a6a9ba..ff41b5e118 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -18,6 +18,7 @@ flowchart TD pkg_launch_environment["launch-environment"] pkg_native_command["native-command"] pkg_output_retention["output-retention"] + pkg_patch_file["patch-file"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] pkg_util_time["util-time"] @@ -238,6 +239,7 @@ flowchart TD pkg_host_directory_picker_native["host-directory-picker-native"] pkg_host_frontend_static["host-frontend-static"] pkg_host_plugin_inventory["host-plugin-inventory"] + pkg_host_plugin_manager["host-plugin-manager"] pkg_host_webserver["host-webserver"] end subgraph group_identity["packages/identity"] @@ -265,6 +267,7 @@ flowchart TD end subgraph group_preset["packages/preset"] pkg_agent_presets["agent-presets"] + pkg_global_tool_mask["global-tool-mask"] pkg_persona["persona"] end subgraph group_runtime_diagnostics["packages/runtime-diagnostics"] @@ -815,6 +818,7 @@ flowchart TD pkg_agent_presets --> pkg_atomic_write pkg_agent_presets --> pkg_home_paths pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_patch_file pkg_agent_presets --> pkg_scope pkg_agent_presets --> pkg_session pkg_agent_presets --> pkg_session_projection @@ -822,6 +826,7 @@ flowchart TD pkg_agent_presets --> pkg_system_prompt pkg_agent_presets --> pkg_tools pkg_agent_presets --> pkg_typert_protocol + pkg_global_tool_mask --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -925,6 +930,11 @@ flowchart TD pkg_host_plugin_inventory --> pkg_app_boot pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol + pkg_host_plugin_manager --> pkg_agent_presets + pkg_host_plugin_manager --> pkg_app_boot + pkg_host_plugin_manager --> pkg_patch_file + pkg_host_plugin_manager --> pkg_typert_protocol + pkg_host_plugin_manager --> pkg_util_values pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm @@ -1161,6 +1171,7 @@ flowchart TD | [`launch-environment`](../packages/util/launch-environment) | `util` | — | | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | +| [`patch-file`](../packages/util/patch-file) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`util-crypto`](../packages/util/crypto) | `util` | — | | [`util-time`](../packages/util/time) | `util` | — | @@ -1357,7 +1368,8 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`patch-file`](../packages/util/patch-file), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`global-tool-mask`](../packages/preset/global-tool-mask) | `preset` | [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1378,6 +1390,7 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index bad358e770..370ea852dc 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -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 docs/subsystems/core.md -core.md: 70d12007ba1675cece6f0ed9c86ac89fcdfebf4e -core.zh.md: 2bc19d2d1dbea704567de86494da3696d02705bd +core.md: df34d06554cf11fabd107e5b288329298653e710 +core.zh.md: 85234c6fca3a3584055fa36cfa17fe7aa30c680d diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 70d12007ba..df34d06554 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -542,6 +542,28 @@ composedPreset(agentCtx: Context): string | undefined */ async read(id: string): Promise +/** + * Where one preset's user patch layer is, or would be written: the layer + * discovery attached, else the writable root's slot of the same id — beside + * the composition for a locally authored preset, alone in the slot for a + * shipped one. The file need not exist yet. + * @param id - the preset id. + * @returns the absolute path of the layer file. + * @throws when the preset is unknown, or it has no layer and the + * deployment configures no writable root. + */ +async overlayPathFor(id: string): Promise + +/** + * Delete one preset's user patch layer, so the next generation composes the + * preset exactly as its root supplies it. Sessions already joined keep the + * generation they run on. + * @param id - the preset id. + * @returns true when a layer was removed; false when the preset had none. + * @throws when the preset is unknown or its layer lies outside the writable root. + */ +async removeOverlay(id: string): Promise + /** * One preset's composition text with the roster row it belongs to. * @param agentPreset - the preset id. @@ -838,6 +860,117 @@ roots(): Agent[] Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) + + +### `ctx.pluginManager` — `PluginManager` + +The `pluginManager` service and the `plugins` Remote. + +Every method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start. + +```ts cordis-catalog +/** + * Every package the profile knows: its template and installed bundles, + * and every other installed dependency. + * @returns one view per package, bundles first in layer order. + */ +@Remote('list') async list(): Promise + +/** + * Install a package into the profile with pnpm, probe it, and leave it + * disabled unless asked otherwise. The run's output streams as + * `plugins/install-log` chunks carrying the returned `jobId`. + * @param spec - what to install, in pnpm's own vocabulary: a registry + * name, a `github:` or git URL, a tarball, or an absolute path. + * @param options - `enable` puts every newly installed bundle into the layer list at once. + * @returns what the run installed and enabled. + * @throws {RemoteError} `plugins/install-failed` when pnpm exits non-zero + * or the run times out, `plugins/enable-failed` when enabling was asked + * for and the tree rejected the bundle. + */ +@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise + +/** + * Remove a package from the profile: disable it when enabled, drop every + * user-layer row that names it, run `pnpm remove`, and forget its probe. + * @param packageName - the installed dependency to remove. + * @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero. + */ +@Remote('uninstall') async uninstall(packageName: string): Promise + +/** + * Put an installed bundle into the layer list and, on a live profile, + * recompose the tree with it. A rejected recomposition restores the list + * and reports the tree's reason; the tree that was running keeps running. + * @param packageName - the installed bundle. + * @returns whether the list changed and whether the change is live. + * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable` + * for a package that declares no bundle or whose probe refused it, or + * `plugins/enable-failed`. + */ +@Remote('enable') async enable(packageName: string): Promise + +/** + * Take a bundle out of the layer list and, on a live profile, recompose + * the tree without it. + * @param packageName - the enabled bundle. + * @returns whether the list changed and whether the change is live. + * @throws {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency. + */ +@Remote('disable') async disable(packageName: string): Promise + +/** + * Compose an enabled bundle again from scratch: its group leaves the tree + * and returns, so rows that failed at boot get another start. + * @param packageName - the enabled bundle. + * @returns the enable outcome of the second step. + * @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures. + */ +@Remote('retry') async retry(packageName: string): Promise + +/** + * Add a row naming one of the package's modules to a user layer: the + * profile's global `cordis.patch.yml`, or an agent preset's. + * @param packageName - the installed package. + * @param target - which layer. + * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`), + * `id` overrides the derived row id, `config` overrides the declared default. + * @returns where the row landed. + * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable` + * when the module is not one the probe found addable, `plugins/row-conflict`, + * or `plugins/unavailable` for a preset target without a roster. + */ +@Remote('addRow') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise + +/** + * Remove a row a user layer inserted. + * @param target - which layer. + * @param rowId - the inserted row's id. + * @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row. + */ +@Remote('removeRow') async removeRow(target: PluginRowTarget, rowId: string): Promise + +/** + * Switch one row off or on in a user layer. Deny-only: `true` writes + * `disabled: true` for the row, `false` removes that key, so a bundle's + * own `!!js` gate is restored rather than overridden. + * @param target - which layer. + * @param rowId - the row's id as the composition declares it. + * @param disabled - whether the layer should switch the row off. + */ +@Remote('setRowDisabled') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise + +/** + * What disabling or removing a package would strand: services its rows + * provide that rows outside it inject, and user-layer rows naming its modules. + * @param packageName - the package. + * @returns the dependents. + */ +@Remote('dependents') async dependents(packageName: string): Promise +``` + +Source: [`packages/host/plugin-manager/src/index.ts`](../../packages/host/plugin-manager/src/index.ts) + ### `ctx.profileRuntime` — `ProfileRuntime` @@ -1243,4 +1376,42 @@ One session committed a different agent preset to its durable log. Consumers inv ``` Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts) + + + +### `plugins/*` events + + + +#### `plugins/changed` — emit + +The manager changed what is installed, enabled, or composed. + +```ts cordis-catalog +/** + * The manager changed what is installed, enabled, or composed. + * @mode emit + * @param change - why, and which package when one is concerned. + */ +'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void +``` + +Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts) + + + +#### `plugins/install-log` — emit + +One chunk of an install run's output, in order; the last chunk carries the exit code. + +```ts cordis-catalog +/** + * One chunk of an install run's output, in order; the last chunk carries the exit code. + * @mode emit + * @param chunk - the chunk. + */ +'plugins/install-log'(chunk: PluginInstallLogChunk): void +``` + +Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2bc19d2d1d..85234c6fca 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -552,6 +552,28 @@ composedPreset(agentCtx: Context): string | undefined */ async read(id: string): Promise +/** + * Where one preset's user patch layer is, or would be written: the layer + * discovery attached, else the writable root's slot of the same id — beside + * the composition for a locally authored preset, alone in the slot for a + * shipped one. The file need not exist yet. + * @param id - the preset id. + * @returns the absolute path of the layer file. + * @throws when the preset is unknown, or it has no layer and the + * deployment configures no writable root. + */ +async overlayPathFor(id: string): Promise + +/** + * Delete one preset's user patch layer, so the next generation composes the + * preset exactly as its root supplies it. Sessions already joined keep the + * generation they run on. + * @param id - the preset id. + * @returns true when a layer was removed; false when the preset had none. + * @throws when the preset is unknown or its layer lies outside the writable root. + */ +async removeOverlay(id: string): Promise + /** * One preset's composition text with the roster row it belongs to. * @param agentPreset - the preset id. @@ -848,6 +870,117 @@ roots(): Agent[] Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) + + +### `ctx.pluginManager` — `PluginManager` + +The `pluginManager` service and the `plugins` Remote. + +Every method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start. + +```ts cordis-catalog +/** + * Every package the profile knows: its template and installed bundles, + * and every other installed dependency. + * @returns one view per package, bundles first in layer order. + */ +@Remote('list') async list(): Promise + +/** + * Install a package into the profile with pnpm, probe it, and leave it + * disabled unless asked otherwise. The run's output streams as + * `plugins/install-log` chunks carrying the returned `jobId`. + * @param spec - what to install, in pnpm's own vocabulary: a registry + * name, a `github:` or git URL, a tarball, or an absolute path. + * @param options - `enable` puts every newly installed bundle into the layer list at once. + * @returns what the run installed and enabled. + * @throws {RemoteError} `plugins/install-failed` when pnpm exits non-zero + * or the run times out, `plugins/enable-failed` when enabling was asked + * for and the tree rejected the bundle. + */ +@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise + +/** + * Remove a package from the profile: disable it when enabled, drop every + * user-layer row that names it, run `pnpm remove`, and forget its probe. + * @param packageName - the installed dependency to remove. + * @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero. + */ +@Remote('uninstall') async uninstall(packageName: string): Promise + +/** + * Put an installed bundle into the layer list and, on a live profile, + * recompose the tree with it. A rejected recomposition restores the list + * and reports the tree's reason; the tree that was running keeps running. + * @param packageName - the installed bundle. + * @returns whether the list changed and whether the change is live. + * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable` + * for a package that declares no bundle or whose probe refused it, or + * `plugins/enable-failed`. + */ +@Remote('enable') async enable(packageName: string): Promise + +/** + * Take a bundle out of the layer list and, on a live profile, recompose + * the tree without it. + * @param packageName - the enabled bundle. + * @returns whether the list changed and whether the change is live. + * @throws {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency. + */ +@Remote('disable') async disable(packageName: string): Promise + +/** + * Compose an enabled bundle again from scratch: its group leaves the tree + * and returns, so rows that failed at boot get another start. + * @param packageName - the enabled bundle. + * @returns the enable outcome of the second step. + * @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures. + */ +@Remote('retry') async retry(packageName: string): Promise + +/** + * Add a row naming one of the package's modules to a user layer: the + * profile's global `cordis.patch.yml`, or an agent preset's. + * @param packageName - the installed package. + * @param target - which layer. + * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`), + * `id` overrides the derived row id, `config` overrides the declared default. + * @returns where the row landed. + * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable` + * when the module is not one the probe found addable, `plugins/row-conflict`, + * or `plugins/unavailable` for a preset target without a roster. + */ +@Remote('addRow') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise + +/** + * Remove a row a user layer inserted. + * @param target - which layer. + * @param rowId - the inserted row's id. + * @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row. + */ +@Remote('removeRow') async removeRow(target: PluginRowTarget, rowId: string): Promise + +/** + * Switch one row off or on in a user layer. Deny-only: `true` writes + * `disabled: true` for the row, `false` removes that key, so a bundle's + * own `!!js` gate is restored rather than overridden. + * @param target - which layer. + * @param rowId - the row's id as the composition declares it. + * @param disabled - whether the layer should switch the row off. + */ +@Remote('setRowDisabled') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise + +/** + * What disabling or removing a package would strand: services its rows + * provide that rows outside it inject, and user-layer rows naming its modules. + * @param packageName - the package. + * @returns the dependents. + */ +@Remote('dependents') async dependents(packageName: string): Promise +``` + +Source: [`packages/host/plugin-manager/src/index.ts`](../../packages/host/plugin-manager/src/index.ts) + ### `ctx.profileRuntime` — `ProfileRuntime` @@ -1253,4 +1386,42 @@ One session committed a different agent preset to its durable log. Consumers inv ``` Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts) + + + +### `plugins/*` events + + + +#### `plugins/changed` — emit + +The manager changed what is installed, enabled, or composed. + +```ts cordis-catalog +/** + * The manager changed what is installed, enabled, or composed. + * @mode emit + * @param change - why, and which package when one is concerned. + */ +'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void +``` + +Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts) + + + +#### `plugins/install-log` — emit + +One chunk of an install run's output, in order; the last chunk carries the exit code. + +```ts cordis-catalog +/** + * One chunk of an install run's output, in order; the last chunk carries the exit code. + * @mode emit + * @param chunk - the chunk. + */ +'plugins/install-log'(chunk: PluginInstallLogChunk): void +``` + +Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index ad2b50137d..a3576a791c 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -70,6 +70,7 @@ "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-host-plugin-manager": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index e7fc7757e9..97058cc996 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -8,6 +8,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' +import pluginManagerRemote from '@deepseek-ai/dsh-host-plugin-manager/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' @@ -23,6 +24,8 @@ export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' +export type {} from '@deepseek-ai/dsh-host-plugin-manager/remote' +export type * from '@deepseek-ai/dsh-host-plugin-manager/types' export type {} from '@deepseek-ai/dsh-message-feedback/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' export type {} from '@deepseek-ai/dsh-subagent/remote' @@ -145,7 +148,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { try { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, - pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, + pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 815a7b66a0..9f629bdaf9 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -7,6 +7,7 @@ */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type {} from '@deepseek-ai/dsh-host-plugin-manager/types' import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' /** @@ -30,6 +31,8 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'cordis/inspect-query', mode: 'emit' }, { event: 'cordis/inspect-query-resolved', mode: 'emit' }, { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'plugins/changed', mode: 'emit' }, + { event: 'plugins/install-log', mode: 'emit' }, { event: 'settings/document-updated', mode: 'emit' }, { event: 'user-questions/request', mode: 'waterfall' }, ] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 56f664d499..88c7f414ee 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -42,6 +42,9 @@ { "path": "../../host/plugin-inventory" }, + { + "path": "../../host/plugin-manager" + }, { "path": "../../interaction/commands" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index c91c11baa2..ab06d27392 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -32,6 +32,9 @@ { "path": "../../preset/agent-presets" }, + { + "path": "../../host/plugin-manager" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index cfcd529dc2..5dd1010814 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -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: 0190a9e1d4c4b6d775aa84ee608c763555acbcce -README.zh.md: 6b3da08c8cbf5dcc68bf3d4991dda06c170c72af +README.md: 8ab2f482263252e793478d4e45afed3e4ee3666d +README.zh.md: 51687c5af81715354b33427d824d62083e83b160 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 0190a9e1d4..8ab2f48226 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -56,7 +56,7 @@ Profiles with `patchReload: live` watch both user patch files: a valid edit reco A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/`, every row id is prefixed `/`, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled. -After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one a runtime bundle enable or install uses. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits. +After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts and the installation anchor, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one the [plugin manager](../../host/plugin-manager/README.md) uses to enable or retry a bundle; `recordContainedStates` is what such a caller runs afterwards, because the boot audit does not run again. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits. ### Previewing the effective configuration @@ -101,9 +101,9 @@ The exports each own one stage of the boot: config resolution and snapshot repla | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit, patch parsing, config dump, harness-source section | -| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with trust and stage, module fallback | -| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, id prefixing, patch rewriting) and the manifest operations behind install, enable, and disable | +| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit and `recordContainedStates`, patch-file loading through `dsh-patch-file`, config dump, harness-source section | +| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with `layerTrust` and stage, module fallback | +| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, id prefixing, patch rewriting), `bundleLayerPatches`, and the manifest operations behind install, enable, and disable | | [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry | | [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: profile facts, row provenance, user-disabled rows, recomposition | | [`src/probe.ts`](src/probe.ts) | The child-process package probe and its per-profile cache | diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 6b3da08c8c..51687c5af8 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -56,7 +56,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head 用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/` 的受控组下,每个行 id 都加上 `/` 前缀,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。 -树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,运行时启用或安装组合包也走它。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。 +树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实与安装锚点,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,[插件管理器](../../host/plugin-manager/README.zh.md) 启用或重试组合包也走它;这样的调用方之后会运行 `recordContainedStates`,因为启动审计不会再跑一次。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。 ### 预览生效配置 @@ -101,9 +101,9 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计、patch 解析、配置 dump、harness 源码段落 | -| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 trust 与 stage 的组合包解析、模块后备机制 | -| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、id 前缀、patch 改写)与安装、启用、停用背后的 manifest 操作 | +| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计与 `recordContainedStates`、经 `dsh-patch-file` 加载 patch 文件、配置 dump、harness 源码段落 | +| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 `layerTrust` 与 stage 的组合包解析、模块后备机制 | +| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、id 前缀、patch 改写)、`bundleLayerPatches`,与安装、启用、停用背后的 manifest 操作 | | [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 | | [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:profile 事实、行来源、用户停用的行、重新组合 | | [`src/probe.ts`](src/probe.ts) | 子进程包探针及其按 profile 的缓存 | diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index ee235d5ae3..1c8c327c50 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -28,6 +28,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-patch-file": "workspace:^", "js-yaml": "^4.2.0", "resolve.exports": "^2.0.3" }, diff --git a/packages/boot/app-boot/src/contained-group.ts b/packages/boot/app-boot/src/contained-group.ts index 19c4d5d53c..c4549c3ed7 100644 --- a/packages/boot/app-boot/src/contained-group.ts +++ b/packages/boot/app-boot/src/contained-group.ts @@ -8,9 +8,24 @@ * @module @deepseek-ai/dsh-app-boot/contained-group */ -import type { Context } from '@deepseek-ai/cordis' +import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis' import { Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +/** Runtime mirror: FiberState is a cross-package const enum. */ +const FIBER_PENDING = 0 as FiberState.PENDING + +/** + * The diagnostic line for a fiber waiting on services, naming the ones its + * context cannot resolve: no plugin error exists for that state. + * @param fiber - the pending fiber. + * @returns one line, `pending (waiting for service: x)`. + */ +export function pendingMessage(fiber: Fiber): string { + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + const subject = missing.length === 1 ? 'service' : 'services' + return `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})` +} + /** The lifecycle step at which a contained row failed. */ export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'unknown' @@ -101,7 +116,24 @@ export class ContainedGroup extends Group { override async create(options: Omit): Promise { try { const id = await super.create(options) - this.registry()?.clear(id) + // A created row is not a mounted row: one waiting for a service resolves + // `create` in the pending state, and a later reload re-creates every row + // of the group, so the record must follow what the row came to. The + // store is keyed by the row id `ensureId` assigned; `id` is tree-wide. + const rowId = (options as EntryOptions).id + const fiber = this.tree.store[rowId]?.fiber + if (fiber !== undefined && fiber.state === FIBER_PENDING) { + this.registry()?.record({ + entryId: id, + rowId, + moduleName: options.name, + groupId: this.groupId(), + stage: 'inject-pending', + message: pendingMessage(fiber), + }) + } else { + this.registry()?.clear(id) + } return id } catch (error) { const registry = this.registry() diff --git a/packages/boot/app-boot/src/external-bundles.ts b/packages/boot/app-boot/src/external-bundles.ts index d7eb73b98d..84461e9ca6 100644 --- a/packages/boot/app-boot/src/external-bundles.ts +++ b/packages/boot/app-boot/src/external-bundles.ts @@ -142,6 +142,18 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer return { patches: [{ insert: [group] }, ...trailing], rows, overrides } } +/** + * The patches one bundle layer contributes. A built-in layer, or an external + * layer the profile stages at boot, mounts its patches as written; every other + * external layer mounts as one contained, id-prefixed group. + * @param layer - the resolved layer. + * @returns the layer's patches in application order. + */ +export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] { + if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches + return layer.patches +} + /** * Whether an installed dependency exports a profile patch, i.e. is a bundle. * @param binName - the diagnostic prefix used by manifest reads. diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 4b61d902e7..02ed44c5b4 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -14,12 +14,14 @@ import * as yaml from 'js-yaml' import { Context, type FiberState } from '@deepseek-ai/cordis' import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { parsePatchList } from '@deepseek-ai/dsh-patch-file' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/cordis-plugin-hmr' import type {} from '@deepseek-ai/dsh-system-prompt' -import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts' +import { + pendingMessage, ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -29,6 +31,7 @@ declare module '@deepseek-ai/cordis' { } export { + layerTrust, composeEntries, DEFAULT_PROFILE_BUNDLES, DEFAULT_PROFILE_PATCH_RELOAD, @@ -59,6 +62,7 @@ export { type ContainedFailure, type ContainedFailureStage, } from './contained-group.ts' export { + bundleLayerPatches, BUNDLE_GROUP_PREFIX, bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle, exportsBundlePatch, externalRowId, isJsDisabled, reconcileInstalledBundles, type BundleReconciliation, type ComposedExternalLayer, type ExternalRowOrigin, @@ -239,13 +243,6 @@ export function loadLayeredEnv( const bootstrapIncludes = new WeakMap() -// The include's YAML dialect (`!!js` scalars become expression nodes the -// Loader interpolates against each entry's injection-ready context), imported -// from the include itself so patch parsing and config dumping can never drift -// from what the include mounts. User patch layers share it so they may -// reference `process.env`. -const userPatchesSchema = entryListSchema - /** Options for live user patch-layer reconciliation. */ export interface UserPatchWatchOptions { /** Diagnostic prefix used by {@link loadOptionalPatches}. */ @@ -344,51 +341,6 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ return parsePatchList(binName, file, content, 'overlay') } -/** Resolve relative plugin paths in one patch file's `insert` rows without changing assertion names. */ -function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] { - const base = dirname(resolve(file)) - const visit = (entry: EntryOptions): void => { - if (typeof entry.name === 'string' && (entry.name.startsWith('./') || entry.name.startsWith('../'))) { - entry.name = pathToFileURL(resolve(base, entry.name)).href - } - if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit) - } - for (const patch of patches) patch.insert?.forEach(visit) - return patches -} -/** - * Parse one loader patch list: a top-level YAML array of - * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and - * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, - * because a patch file that cannot be applied at all is a misconfiguration; a - * single patch whose target row is absent stays a per-entry Loader warning, so - * one overlay shared across surfaces does not have to match every tree. - * @param binName - the diagnostic prefix on the thrown error. - * @param file - the source path, quoted in errors. - * @param content - the file's text. - * @param label - what to call this list in errors (`patches`, `overlay`). - * @returns the parsed patch list. - */ -function parsePatchList( - binName: string, file: string, content: string, label: string, -): PatchOptions[] { - let parsed: unknown - try { - parsed = yaml.load(content, { schema: userPatchesSchema }) - } catch (error) { - throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) - } - if (!Array.isArray(parsed)) { - throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`) - } - parsed.forEach((entry, index) => { - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) - } - }) - return anchorInsertedPluginNames(parsed as PatchOptions[], file) -} - /** One overlay patch list with the source label printed in dump comments. */ export interface ConfigDumpLayer { /** Source name shown in dump comments (a file basename or path). */ @@ -802,6 +754,50 @@ function owningGroupId(entry: Entry): string { */ export async function assertEntriesActivated(ctx: Context, binName: string): Promise { assertEntriesLoaded(ctx, binName) + const { failures, builtinPending } = await auditEntries(ctx) + const registry = ctx.get('pluginFailures') + if (failures.length > 0) { + const noun = failures.length === 1 ? 'entry' : 'entries' + // A contained failure is only harmless while nothing built-in depends on + // it: a built-in row left waiting for a service names the isolated + // bundles, because one of them is the likely missing provider. + const isolated = builtinPending ? registry?.list() ?? [] : [] + const hint = isolated.length === 0 + ? '' + : `\n${binName}: isolated bundle failure(s) may be the missing provider — ` + + `check ${[...new Set(isolated.map(failure => failure.groupId))].join(', ')} ` + + 'or mark that bundle `dsh.bundle.stage: boot` so it fails loud' + throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}${hint}`) + } +} + +/** + * Record the state of every contained row after a runtime recomposition — + * a bundle enabled or retried while the tree runs — the way the boot audit + * records it: an active row clears its record, a failed or waiting row is + * recorded. Built-in rows are left to the caller's own policy; nothing here + * throws. + * @param ctx - the context whose Loader entries to walk. + * @returns the diagnostics the boot audit would have raised for built-in rows. + */ +export async function recordContainedStates(ctx: Context): Promise { + return (await auditEntries(ctx)).failures +} + +/** What one walk of the Loader entries found beyond the contained records it wrote. */ +interface EntryAudit { + /** One line per built-in entry that failed or is not active. */ + failures: string[] + /** Whether a built-in entry is waiting for a service. */ + builtinPending: boolean +} + +/** + * Walk every enabled entry: clear or record contained rows, collect built-in + * diagnostics, and pass built-in rejections through the process checkpoint + * so the fail-loud handler does not exit over a rejection the audit reports. + */ +async function auditEntries(ctx: Context): Promise { const failures: string[] = [] const rejectionReasons: unknown[] = [] const registry = ctx.get('pluginFailures') @@ -832,9 +828,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro continue } if (state === FIBER_PENDING) { - const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) - const subject = missing.length === 1 ? 'service' : 'services' - const line = `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})` + const line = pendingMessage(fiber) if (contained) { registry?.record({ entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name, @@ -848,22 +842,10 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro failures.push(`${entry.options.name}: fiber state ${String(state)}`) } } - if (failures.length > 0) { - if (rejectionReasons.length > 0) { - await observeLoaderRejectionCheckpoint(rejectionReasons) - } - const noun = failures.length === 1 ? 'entry' : 'entries' - // A contained failure is only harmless while nothing built-in depends on - // it: a built-in row left waiting for a service names the isolated - // bundles, because one of them is the likely missing provider. - const isolated = builtinPending ? registry?.list() ?? [] : [] - const hint = isolated.length === 0 - ? '' - : `\n${binName}: isolated bundle failure(s) may be the missing provider — ` - + `check ${[...new Set(isolated.map(failure => failure.groupId))].join(', ')} ` - + 'or mark that bundle `dsh.bundle.stage: boot` so it fails loud' - throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}${hint}`) + if (rejectionReasons.length > 0) { + await observeLoaderRejectionCheckpoint(rejectionReasons) } + return { failures, builtinPending } } /** diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts index 6bbd2c0cda..ae0335c1cb 100644 --- a/packages/boot/app-boot/src/profile-runtime.ts +++ b/packages/boot/app-boot/src/profile-runtime.ts @@ -38,6 +38,8 @@ export interface RowOrigin { export interface ProfileRuntimeOptions { /** The profile as booted. */ profile: Profile + /** Absolute path of the dsh app's package.json: the first resolution anchor for profile packages. */ + installAnchor: string /** Re-read the profile from disk, re-resolving its bundle layers. */ loadProfile: () => Profile /** The complete patch stack for a profile: bundle layers, user layers, overlays. */ @@ -77,6 +79,11 @@ export class ProfileRuntime extends Service { return this.profile.name } + /** Absolute path of the dsh app's package.json, the anchor profile packages resolve from. */ + get installAnchor(): string { + return this.options.installAnchor + } + /** Absolute profile directory. */ get dir(): string { return this.profile.dir diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 5178572b28..6131beeb26 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -741,6 +741,21 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') } +/** + * Who supplied one bundle of a profile. Provenance, not the package name, + * decides: a template bundle is never a dependency, and everything pnpm + * added is out-of-tree — a fork that kept a first-party name still lands in + * `dependencies` — unless the profile lists it under `dsh.profile.firstParty`. + * @param manifest - the profile manifest. + * @param packageName - the bundle's package name. + * @returns `external` for an installed third-party bundle, else `builtin`. + */ +export function layerTrust(manifest: ProfileManifest, packageName: string): BundleTrust { + const installed = packageName in (manifest.dependencies ?? {}) + const firstParty = manifest.dsh?.profile?.firstParty ?? [] + return installed && !firstParty.includes(packageName) ? 'external' : 'builtin' +} + /** * Validate one bundle stage value read from a manifest; an unknown value is a * misconfiguration and fails at load. @@ -872,8 +887,6 @@ export function loadProfile( ) } const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD - const dependencies = new Set(Object.keys(manifest.dependencies ?? {})) - const firstParty = new Set(manifest.dsh?.profile?.firstParty ?? []) const stages = manifest.dsh?.profile?.stages ?? {} const layers = bundles.map((packageName): ProfileLayer => { const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) @@ -883,10 +896,7 @@ export function loadProfile( throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } const patchPath = join(packageDir, declared) - // Provenance, not the package name, decides trust: a template bundle is - // never a dependency, and everything pnpm added is out-of-tree — a fork - // that kept a first-party name still lands in `dependencies`. - const trust: BundleTrust = dependencies.has(packageName) && !firstParty.has(packageName) ? 'external' : 'builtin' + const trust = layerTrust(manifest, packageName) const stage = readBundleStage(binName, packageName, stages[packageName] ?? bundleManifest.dsh?.bundle?.stage) return { packageName, diff --git a/packages/boot/app-boot/tests/contained-group.spec.ts b/packages/boot/app-boot/tests/contained-group.spec.ts index 25875552c6..6d6419afb5 100644 --- a/packages/boot/app-boot/tests/contained-group.spec.ts +++ b/packages/boot/app-boot/tests/contained-group.spec.ts @@ -97,6 +97,30 @@ describe('cordis:contained-group', () => { expect(registry.get('include:ext/waiting')?.message).toContain('neverReady') }) + it('keeps recording a waiting row when a reload re-creates its group', async () => { + const ctx = await boot(NAME, stage(` +- id: bundle/ext + name: cordis:contained-group + group: true + config: + - id: ext/waiting + name: cordis:pending +- id: other + name: cordis:good +`), [], prepare) + contexts.push(ctx) + const registry = ctx.get('pluginFailures') as ContainedFailureRegistry + expect(registry.get('include:ext/waiting')?.stage).toBe('inject-pending') + + const root = rootIncludeEntry(ctx) + if (root === undefined) throw new Error('no root include') + const { patches: _patches, ...config } = root.options.config as { path: string; patches?: unknown } + await root.update({ config: { ...config, patches: [{ id: 'other', disabled: true }] } }) + + expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', rowId: 'ext/waiting' })) + expect(registry.get('include:ext/waiting')?.message).toBe('pending (waiting for service: neverReady)') + }) + it('still fails the boot for a built-in row, and names isolated bundles when a built-in row is left waiting', async () => { await expect(boot(NAME, stage(` - id: builtin-bad diff --git a/packages/boot/app-boot/tests/profile-runtime.spec.ts b/packages/boot/app-boot/tests/profile-runtime.spec.ts index b7cbb9cae7..28ebb28071 100644 --- a/packages/boot/app-boot/tests/profile-runtime.spec.ts +++ b/packages/boot/app-boot/tests/profile-runtime.spec.ts @@ -31,6 +31,7 @@ async function harness( const compose = vi.fn((current: Profile) => [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]) await ctx.plugin(ProfileRuntime, { profile: profile(layers), + installAnchor: '/install/package.json', loadProfile: () => options.reloaded ?? profile(layers), compose, rootEntry: options.rootEntry ?? (() => undefined), @@ -43,6 +44,7 @@ describe('ProfileRuntime', () => { it('exposes the booted profile\'s facts', async () => { const { runtime } = await harness([layer('@deepseek-ai/dsh-base', 'builtin', [])]) expect(runtime.profileName).toBe('web') + expect(runtime.installAnchor).toBe('/install/package.json') expect(runtime.dir).toBe('/profiles/web') expect(runtime.patchPath).toBe('/profiles/web/cordis.patch.yml') expect(runtime.patchReload).toBe('live') diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index bb1ce09d7b..979313197c 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../util/atomic-write" }, + { + "path": "../../util/patch-file" + }, { "path": "../../util/launch-environment" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 2c9bd25990..0a687198ee 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -87,6 +87,13 @@ - id: plugin-inventory name: '@deepseek-ai/dsh-host-plugin-inventory' + # Plugin management over the booted profile: install, enable, disable, + # retry, and user-layer rows through the `plugins` Remote. Resolves the + # profile runtime per call, so a composition booted without the profile + # launcher still starts and answers `plugins/unavailable`. + - id: plugin-manager + name: '@deepseek-ai/dsh-host-plugin-manager' + # Session commands, cold reads, and live control over Typert Remote. - id: session-controller name: '@deepseek-ai/dsh-api-session-controller' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 46e1ebe45c..b37676b159 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -94,6 +94,7 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-host-plugin-manager": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-file-reference-local": "workspace:^", diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 09db7eb4b4..1a6c4079ef 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -305,7 +305,7 @@ describe('PluginInventorySettingsTab', () => { id: 'solo', trust: 'user', isDefault: false, - rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }], + rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null, source: 'preset' }], }], }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 12324f392c..9ac6e8c2d9 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -187,6 +187,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the composition exactly as stored.', throws: ['when no configured root supplies that id.'], }, + { + signature: 'async overlayPathFor(id: string): Promise', + description: 'Where one preset\'s user patch layer is, or would be written: the layer discovery attached, else the writable root\'s slot of the same id — beside the composition for a locally authored preset, alone in the slot for a shipped one. The file need not exist yet.', + parameters: [{ name: 'id', description: 'the preset id.' }], + returns: 'the absolute path of the layer file.', + throws: ['when the preset is unknown, or it has no layer and the deployment configures no writable root.'], + }, + { + signature: 'async removeOverlay(id: string): Promise', + description: 'Delete one preset\'s user patch layer, so the next generation composes the preset exactly as its root supplies it. Sessions already joined keep the generation they run on.', + parameters: [{ name: 'id', description: 'the preset id.' }], + returns: 'true when a layer was removed; false when the preset had none.', + throws: ['when the preset is unknown or its layer lies outside the writable root.'], + }, { signature: '@Remote(\'read\') async readDocument(agentPreset: string): Promise', description: 'One preset\'s composition text with the roster row it belongs to.', @@ -1287,6 +1301,77 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'pluginManager', + summary: 'The `pluginManager` service and the `plugins` Remote.', + description: 'The `pluginManager` service and the `plugins` Remote.\n\nEvery method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start.', + methods: [ + { + signature: '@Remote(\'list\') async list(): Promise', + description: 'Every package the profile knows: its template and installed bundles, and every other installed dependency.', + parameters: [], + returns: 'one view per package, bundles first in layer order.', + }, + { + signature: '@Remote(\'install\') async install(spec: string, options?: { enable?: boolean }): Promise', + description: 'Install a package into the profile with pnpm, probe it, and leave it disabled unless asked otherwise. The run\'s output streams as `plugins/install-log` chunks carrying the returned `jobId`.', + parameters: [{ name: 'spec', description: 'what to install, in pnpm\'s own vocabulary: a registry name, a `github:` or git URL, a tarball, or an absolute path.' }, { name: 'options', description: '`enable` puts every newly installed bundle into the layer list at once.' }], + returns: 'what the run installed and enabled.', + throws: ['{RemoteError} `plugins/install-failed` when pnpm exits non-zero or the run times out, `plugins/enable-failed` when enabling was asked for and the tree rejected the bundle.'], + }, + { + signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise', + description: 'Remove a package from the profile: disable it when enabled, drop every user-layer row that names it, run `pnpm remove`, and forget its probe.', + parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }], + throws: ['{RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.'], + }, + { + signature: '@Remote(\'enable\') async enable(packageName: string): Promise', + description: 'Put an installed bundle into the layer list and, on a live profile, recompose the tree with it. A rejected recomposition restores the list and reports the tree\'s reason; the tree that was running keeps running.', + parameters: [{ name: 'packageName', description: 'the installed bundle.' }], + returns: 'whether the list changed and whether the change is live.', + throws: ['{RemoteError} `plugins/not-installed`, `plugins/not-enableable` for a package that declares no bundle or whose probe refused it, or `plugins/enable-failed`.'], + }, + { + signature: '@Remote(\'disable\') async disable(packageName: string): Promise', + description: 'Take a bundle out of the layer list and, on a live profile, recompose the tree without it.', + parameters: [{ name: 'packageName', description: 'the enabled bundle.' }], + returns: 'whether the list changed and whether the change is live.', + throws: ['{RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.'], + }, + { + signature: '@Remote(\'retry\') async retry(packageName: string): Promise', + description: 'Compose an enabled bundle again from scratch: its group leaves the tree and returns, so rows that failed at boot get another start.', + parameters: [{ name: 'packageName', description: 'the enabled bundle.' }], + returns: 'the enable outcome of the second step.', + throws: ['{RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.'], + }, + { + signature: '@Remote(\'addRow\') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise', + description: 'Add a row naming one of the package\'s modules to a user layer: the profile\'s global `cordis.patch.yml`, or an agent preset\'s.', + parameters: [{ name: 'packageName', description: 'the installed package.' }, { name: 'target', description: 'which layer.' }, { name: 'options', description: '`module` selects a declared `dsh.plugins[]` name (default `.`), `id` overrides the derived row id, `config` overrides the declared default.' }], + returns: 'where the row landed.', + throws: ['{RemoteError} `plugins/not-installed`, `plugins/not-enableable` when the module is not one the probe found addable, `plugins/row-conflict`, or `plugins/unavailable` for a preset target without a roster.'], + }, + { + signature: '@Remote(\'removeRow\') async removeRow(target: PluginRowTarget, rowId: string): Promise', + description: 'Remove a row a user layer inserted.', + parameters: [{ name: 'target', description: 'which layer.' }, { name: 'rowId', description: 'the inserted row\'s id.' }], + throws: ['{RemoteError} `gateway/bad-request` when the layer inserts no such row.'], + }, + { + signature: '@Remote(\'setRowDisabled\') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise', + description: 'Switch one row off or on in a user layer. Deny-only: `true` writes `disabled: true` for the row, `false` removes that key, so a bundle\'s own `!!js` gate is restored rather than overridden.', + parameters: [{ name: 'target', description: 'which layer.' }, { name: 'rowId', description: 'the row\'s id as the composition declares it.' }, { name: 'disabled', description: 'whether the layer should switch the row off.' }], + }, + { + signature: '@Remote(\'dependents\') async dependents(packageName: string): Promise', + description: 'What disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.', + parameters: [{ name: 'packageName', description: 'the package.' }], + returns: 'the dependents.', + }, + ], + }, { key: 'profileRuntime', summary: 'Facts and recomposition of the booted profile.', @@ -3167,6 +3252,22 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmRuntime; call `next()` to reach the resolved adapter\'s stream, or yield your own chunks to short-circuit.', parameters: [{ name: 'options', description: 'the full request. A LOOP-built request carries the process-local {@link markAgentLoopRequest} identity and arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability Agent Note), so listeners read it, never rewrite it. Hand-built calls do not carry that marker; their messages already obey the immutable creation contract.' }], }, + { + name: 'plugins/changed', + mode: 'emit', + signature: '\'plugins/changed\'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void', + summary: 'The manager changed what is installed, enabled, or composed.', + description: 'The manager changed what is installed, enabled, or composed.', + parameters: [{ name: 'change', description: 'why, and which package when one is concerned.' }], + }, + { + name: 'plugins/install-log', + mode: 'emit', + signature: '\'plugins/install-log\'(chunk: PluginInstallLogChunk): void', + summary: 'One chunk of an install run\'s output, in order; the last chunk carries the exit code.', + description: 'One chunk of an install run\'s output, in order; the last chunk carries the exit code.', + parameters: [{ name: 'chunk', description: 'the chunk.' }], + }, { name: 'session-telemetry/record', mode: 'waterfall', @@ -3421,7 +3522,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentPreset', - declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', + declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly overlayPath?: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', }, { name: 'AgentPresetComposition', @@ -3429,7 +3530,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentPresetCompositionRow', - declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n}', + declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n readonly source: CompositionRowSource;\n readonly disabledBy?: CompositionRowDisabledBy;\n}', }, { name: 'AgentPresetDirectoryOpenValue', @@ -3703,10 +3804,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CompactionTrigger', declaration: 'export type CompactionTrigger = \'pressure\' | \'context-overflow\';', }, + { + name: 'CompositionRowDisabledBy', + declaration: 'export type CompositionRowDisabledBy = \'composition\' | \'user\';', + }, { name: 'CompositionRowEnablement', declaration: 'export type CompositionRowEnablement = boolean | \'conditional\';', }, + { + name: 'CompositionRowSource', + declaration: 'export type CompositionRowSource = \'preset\' | \'user\';', + }, { name: 'ConfinedArgv', declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}', @@ -4523,6 +4632,74 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PermissionSelect', declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', }, + { + name: 'PluginChangeReason', + declaration: 'export type PluginChangeReason = \'install\' | \'uninstall\' | \'enable\' | \'disable\' | \'retry\' | \'row\';', + }, + { + name: 'PluginDependents', + declaration: 'export interface PluginDependents {\n readonly services: readonly PluginServiceDependent[];\n readonly references: readonly PluginRowReference[];\n}', + }, + { + name: 'PluginEnableResult', + declaration: 'export interface PluginEnableResult {\n readonly changed: boolean;\n readonly effect: \'live\' | \'restart\';\n}', + }, + { + name: 'PluginInstallLogChunk', + declaration: 'export interface PluginInstallLogChunk {\n readonly jobId: string;\n readonly spec: string;\n readonly stream: \'stdout\' | \'stderr\';\n readonly text: string;\n readonly exitCode?: number | null;\n}', + }, + { + name: 'PluginInstallResult', + declaration: 'export interface PluginInstallResult {\n readonly installed: readonly string[];\n readonly enabled: readonly string[];\n readonly installedOnly: readonly string[];\n readonly plain: readonly string[];\n readonly jobId: string;\n}', + }, + { + name: 'PluginPackageAddableView', + declaration: 'export interface PluginPackageAddableView {\n readonly moduleName: string;\n readonly declaredName: string;\n readonly title?: string;\n readonly config?: JsonValue;\n readonly ok: boolean;\n readonly error?: string;\n readonly configSchema?: JsonValue;\n}', + }, + { + name: 'PluginPackageKind', + declaration: 'export type PluginPackageKind = \'bundle\' | \'plugin\' | \'library\';', + }, + { + name: 'PluginPackageRowView', + declaration: 'export interface PluginPackageRowView {\n readonly entryId: string;\n readonly originalId?: string;\n readonly moduleName: string;\n readonly enabled: boolean;\n readonly disabledBy?: \'user\' | \'composition\';\n readonly phase: PluginRowPhase;\n readonly failure?: {\n readonly stage: string;\n readonly message: string;\n };\n}', + }, + { + name: 'PluginPackageStage', + declaration: 'export type PluginPackageStage = \'boot\' | \'runtime\';', + }, + { + name: 'PluginPackageStatus', + declaration: 'export type PluginPackageStatus = \'running\' | \'partial\' | \'failed\' | \'disabled\' | \'not-enableable\' | \'restart-required\' | \'plain\';', + }, + { + name: 'PluginPackageTrust', + declaration: 'export type PluginPackageTrust = \'builtin\' | \'external\';', + }, + { + name: 'PluginPackageView', + declaration: 'export interface PluginPackageView {\n readonly name: string;\n readonly version?: string;\n readonly title?: string;\n readonly description?: string;\n readonly kind: PluginPackageKind;\n readonly trust: PluginPackageTrust;\n readonly stage: PluginPackageStage;\n readonly installed: boolean;\n readonly enabled: boolean;\n readonly status: PluginPackageStatus;\n readonly reason?: string;\n readonly enginesDsh?: string;\n readonly cordisSameCopy: boolean | null;\n readonly rows: readonly PluginPackageRowView[];\n readonly overrides: readonly string[];\n readonly addable: readonly PluginPackageAddableView[];\n readonly probedAt?: string;\n readonly liveReload: boolean;\n}', + }, + { + name: 'PluginRowAddition', + declaration: 'export interface PluginRowAddition {\n readonly target: PluginRowTarget;\n readonly rowId: string;\n readonly file: string;\n}', + }, + { + name: 'PluginRowPhase', + declaration: 'export type PluginRowPhase = \'pending\' | \'loading\' | \'active\' | \'failed\' | \'unloading\' | null;', + }, + { + name: 'PluginRowReference', + declaration: 'export interface PluginRowReference {\n readonly target: PluginRowTarget;\n readonly rowId: string;\n readonly moduleName: string;\n}', + }, + { + name: 'PluginRowTarget', + declaration: 'export type PluginRowTarget = {\n readonly kind: \'global\';\n} | {\n readonly kind: \'preset\';\n readonly preset: string;\n};', + }, + { + name: 'PluginServiceDependent', + declaration: 'export interface PluginServiceDependent {\n readonly service: string;\n readonly providedBy: string;\n readonly injectedBy: readonly string[];\n}', + }, { name: 'PostToolDecision', declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 1967dba2c5..e687568daf 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -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/host/plugin-inventory/README.md -README.md: 0eaf395ec9ecbdaf56c26c9adc8498b0e6ebaaec -README.zh.md: 60140b175c7f4f7b28cd35e710b92af4c2deaa5d +README.md: d352f4749406b3402718cbfb01e0ca588e813dd0 +README.zh.md: 9cf530aec7824e5e3d7e9d62e4ca6b7e83808378 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index 0eaf395ec9..d352f47494 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -35,7 +35,7 @@ When the profile launcher composed the tree, each row also says who supplied it: ### Per-preset compositions -With a roster composed, `agentPresets` carries one group per preset in roster order: its id, whether the deployment ships it or the user owns it (`trust`, which clients use to localize shipped names), published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, and a root-fiber phase when the composition is live. A preset some session already composed answers from its newest standing generation — even when its file has since broken, because the mount is what those sessions run; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset nothing composed stays listed with its reason and no rows. Without a roster the field is absent. +With a roster composed, `agentPresets` carries one group per preset in roster order: its id, whether the deployment ships it or the user owns it (`trust`, which clients use to localize shipped names), published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, a root-fiber phase when the composition is live, whether the preset's composition file or its user patch layer supplied the row (`source`), and for a row that is off whether the composition or the layer switched it off (`disabledBy`). A preset some session already composed answers from its newest standing generation — even when its file has since broken, because the mount is what those sessions run; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset nothing composed stays listed with its reason and no rows. Without a roster the field is absent. ### What you can and cannot do with it diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 60140b175c..9cf530aec7 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" ### 每个预设的组合 -组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、随部署内置还是用户自建(`trust`,客户端据此本地化内置预设名)、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有),以及组合存活时的根 Fiber 阶段。已有会话组合过的预设由其最新 standing 世代作答——即使其文件事后损坏也是如此,因为挂载才是这些会话实际运行的组合;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;无人组合的坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。 +组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、随部署内置还是用户自建(`trust`,客户端据此本地化内置预设名)、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有)、组合存活时的根 Fiber 阶段、该行来自预设的组合文件还是其用户补丁层(`source`),以及被关掉的行是组合还是补丁层关掉的(`disabledBy`)。已有会话组合过的预设由其最新 standing 世代作答——即使其文件事后损坏也是如此,因为挂载才是这些会话实际运行的组合;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;无人组合的坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。 ### 你能用它做什么、不能做什么 diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index 3668c7cad1..474d6193b4 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -73,6 +73,10 @@ export interface AgentPresetPluginRow { readonly condition?: string /** Root-fiber phase when the composition is live; null otherwise. */ readonly fiberPhase: PluginFiberPhase + /** Whether the preset's composition file or its user patch layer supplied the row. */ + readonly source: 'preset' | 'user' + /** Present exactly when `enabled` is false: the composition's own gate, or the user patch layer. */ + readonly disabledBy?: PluginDisabledBy } /** One agent preset's identity and flattened composition in the inventory. */ diff --git a/packages/host/plugin-manager/README.i18n.yaml b/packages/host/plugin-manager/README.i18n.yaml new file mode 100644 index 0000000000..bed7ad880d --- /dev/null +++ b/packages/host/plugin-manager/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/host/plugin-manager/README.md +README.md: 4e6b9fd69ad1aaa9e760c8ad1db4378ef0f0334f +README.zh.md: 6f1d5796f7b8008ffa5a30336da166d6f8bf852e diff --git a/packages/host/plugin-manager/README.md b/packages/host/plugin-manager/README.md new file mode 100644 index 0000000000..4e6b9fd69a --- /dev/null +++ b/packages/host/plugin-manager/README.md @@ -0,0 +1,132 @@ +--- +description: "Plugin management over the booted profile: the pluginManager service and the plugins Remote that install, enable, disable, and retry bundles, edit user-layer rows, and report every package's state." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-host-plugin-manager + +English | [中文](README.zh.md) + +## Summary + +`dsh-host-plugin-manager` is the one place that changes what a running profile is composed of. The profile launcher composes the host tree from bundle layers and user patch files and, through `profileRuntime`, can recompose it while it runs; this service drives that: it runs pnpm in the profile directory to install or remove a package, moves a bundle in and out of the profile's layer list and recomposes the tree with the outcome, composes a bundle again when its rows failed at boot, adds and removes rows in the profile's global user layer or one agent preset's, and folds the manifest, the probe record, and the live tree into one view per package. The `plugins` Remote exposes each operation; every change is followed by a `plugins/changed` event and an install run streams pnpm's output as `plugins/install-log`. Client packages consume the Remote through the [`api-remotes`](../../api/remotes/README.md) assembly. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the row in a host composition beside the plugin inventory; the web bundle does. The row injects only the Loader and resolves the profile runtime per call, so a composition booted without the profile launcher still starts and answers every call with `plugins/unavailable`. + +### What a package view says + +`plugins/list` returns one view per package the profile knows: its template and installed bundles and every other installed dependency. A view carries the manifest facts (name, version, title, description, `engines.dsh`), what the package is (`bundle`, `plugin`, or `library`), who supplied it (`builtin` or `external`), when its rows mount (`boot` or `runtime`), whether it is installed and enabled, and a folded `status`: `running`, `partial`, or `failed` for an enabled bundle by how many of its rows are active; `disabled` for an installed bundle outside the layer list; `not-enableable` when the probe refused it, with the reason; `restart-required` when the manifest and the live tree disagree on a profile that applies changes at its next start; `plain` for a library or plugin module, which is added to a composition rather than enabled. Rows come from the live tree while the bundle is composed — phase, disabled-by, and the recorded failure of an isolated row — and from the probe record otherwise, with the ids the launcher will prefix them with. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict. + +### Installing and enabling + +`plugins/install` takes a pnpm spec — a registry name, a `github:` or git URL, a tarball, an absolute path — runs `pnpm add` in the profile directory, records what pnpm wrote to `dependencies`, probes every new package in a child process, and leaves new bundles disabled unless `enable` was asked for. pnpm's output arrives as `plugins/install-log` chunks carrying the run's `jobId`; the last chunk carries the exit code. A non-zero exit, a spawn failure, or the timeout fails the call with `plugins/install-failed` and the tail of the log. + +`plugins/enable` puts an installed bundle into the layer list and, on a live profile, recomposes the tree with it through the profile runtime. The recomposition is the Loader's own transaction: a bundle the tree rejects — a `boot`-stage bundle whose row throws — rolls back, the layer list is restored, and the call fails with `plugins/enable-failed` naming the reason, while the tree that was running keeps running. A `runtime`-stage bundle whose row fails is isolated instead: the call succeeds, the view reports the row's failure, and `plugins/retry` composes the bundle again from scratch. `plugins/disable` is the reverse; a template bundle, which is not a dependency, cannot be disabled. On a profile whose `patchReload` is `startup`, both write the manifest and report `effect: 'restart'`. + +`plugins/uninstall` disables the bundle when enabled, drops every user-layer row that names one of the package's modules, runs `pnpm remove`, and forgets the probe record. + +### Rows in user layers + +`plugins/addRow` inserts a row naming one of the package's modules — its main export for a `plugin` package, or a `dsh.plugins` entry — into the profile's global `cordis.patch.yml` (`target: { kind: 'global' }`) or an agent preset's user layer (`{ kind: 'preset', preset }`, through the roster's `overlayPathFor`). The row id derives from the package name and subpath unless given; a taken id fails with `plugins/row-conflict`. `plugins/removeRow` removes an inserted row and `plugins/setRowDisabled` writes or removes a `disabled: true` for any row — deny-only, so a bundle's own `!!js` gate is restored rather than overridden. The global layer is recomposed live on the spot; a preset's layer reaches its next standing generation. + +`plugins/dependents` says what disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules. + +### Configuration + +| Field | Default | Meaning | +|---|---|---| +| `pnpmCommand` | `pnpm` | The executable, resolved through `PATH` like the `dsh plugin` command. | +| `installTimeoutMs` | `600000` | Bound on one install or remove run. | +| `probeTimeoutMs` | `20000` | Bound on one package probe. | +| `installLogTailBytes` | `16384` | How much trailing output an install failure reports. | + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +### One manifest, two writers + +Every change reads the profile manifest afresh and writes it through the same app-boot helpers the `dsh plugin` command uses (`reconcileInstalledBundles`, `enableBundle`, `disableBundle`), so the CLI and the manager never disagree on the file. `dependencies` records what is installed; `dsh.profile.bundles` records what is enabled. + +### pnpm runs through `node:child_process` + +The subprocess seam scrubs secret-shaped variables and has no shell mode, and pnpm needs both the user's registry, proxy, and auth settings and, on Windows, the shell that resolves its `.cmd` shim. The manager therefore spawns pnpm the way the CLI does, with the parent environment and `shell` on Windows, and streams the child's output itself. + +### Retry is disable then enable + +The Loader's transactional update leaves an unchanged row alone, so a failed isolated row would not restart on a plain recomposition. Retry takes the bundle out of the layer list and puts it back: two recompositions, and the manifest ends as it began. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | `PluginManager`: the `pluginManager` service, the `plugins` Remote methods, the pnpm runner, and the view fold | +| [`src/types.ts`](src/types.ts) | Wire payloads, the `plugins/changed` and `plugins/install-log` events, and the `plugins/*` failure codes | +| — | No runtime invariant companion is published; every view is folded from the manifest, the probe cache, and Loader-owned state on each call. | + +Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +
+ +----- + + +## Further Exploration + +Read these when the manager's contract is not enough: the runtime it drives, the files it edits, and the surface that renders it. + +- [App boot](../../boot/app-boot/README.md) — the profile runtime, external bundle isolation, and the package probe. +- [Patch files](../../util/patch-file/README.md) — how user-layer rows are written. +- [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer a preset target writes. +- [Plugin inventory](../plugin-inventory/README.md) — the row-level read-only projection beside this service. + +----- + + +## Model Experience + +None, as the host-side plugin manager registers nothing model-facing; the rows it composes own every registration they make. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + + +These limits define what the manager will not do for a client. They are current package constraints, not a task backlog. + +- **Updating a loaded package needs a restart** — Node caches ESM modules by URL and a hoisted install keeps the path; `pnpm update` through `install` rewrites the files but the running tree keeps the old modules until the process restarts. +- **Dependents stop at injection** — a registry-type dependency (a tool, an LLM adapter) has no `inject` edge, so `dependents` cannot name a row that only reads what the package registered. +- **A preset row is not composed live** — the manager writes the preset's layer; sessions created afterwards compose it, sessions already running keep their generation. +- **No `engines.dsh` check yet** — the range is reported, not enforced against the running harness version. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/host/plugin-manager/README.zh.md b/packages/host/plugin-manager/README.zh.md new file mode 100644 index 0000000000..6f1d5796f7 --- /dev/null +++ b/packages/host/plugin-manager/README.zh.md @@ -0,0 +1,132 @@ +--- +description: "面向已启动 profile 的插件管理:pluginManager 服务与 plugins Remote,负责安装、启用、停用与重试组合包、编辑用户层的行,并报告每个包的状态。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-host-plugin-manager + +[English](README.md) | 中文 + +## 概述 + +`dsh-host-plugin-manager` 是改变一个运行中 profile 由什么组成的唯一地方。profile launcher 从组合包层与用户补丁文件组合出宿主树,并经 `profileRuntime` 在运行中重新组合;本服务驱动这一切:在 profile 目录运行 pnpm 安装或移除包,把组合包移入或移出 profile 的层列表并以结果重新组合树,在组合包的行启动失败时重新组合它,向 profile 的全局用户层或某个 agent preset 的用户层添加与移除行,并把 manifest、探针记录与在线树折叠成每个包一份视图。`plugins` Remote 暴露每一项操作;每次变更之后发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 流式发出。客户端包通过 [`api-remotes`](../../api/remotes/README.zh.md) 装配消费该 Remote。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +把这一行挂在宿主组合里插件清单旁边;web 组合包已经这么做了。该行只注入 Loader,并在每次调用时解析 profile runtime,因此不经 profile launcher 启动的组合仍能启动,只是每次调用都回答 `plugins/unavailable`。 + +### 一份包视图说了什么 + +`plugins/list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,并带上 launcher 将加的前缀 id。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定。 + +### 安装与启用 + +`plugins/install` 接受一个 pnpm spec——registry 名字、`github:` 或 git URL、tarball、绝对路径——在 profile 目录运行 `pnpm add`,记录 pnpm 写进 `dependencies` 的内容,在子进程里探测每个新包,并让新组合包保持停用,除非调用方要求 `enable`。pnpm 的输出以带本次 `jobId` 的 `plugins/install-log` 分块到达;最后一块携带退出码。非零退出、spawn 失败或超时都以 `plugins/install-failed` 与日志尾部让调用失败。 + +`plugins/enable` 把已安装的组合包放进层列表,并在 live profile 上经 profile runtime 带着它重新组合树。这次重新组合就是 Loader 自己的事务:树拒绝的组合包——`boot` 阶段而行抛错的组合包——回滚,层列表恢复,调用以点名原因的 `plugins/enable-failed` 失败,而原本运行的树继续运行。`runtime` 阶段而行失败的组合包则被隔离:调用成功,视图报告该行的失败,`plugins/retry` 从头重新组合它。`plugins/disable` 是反向操作;模板组合包不是依赖,无法停用。在 `patchReload` 为 `startup` 的 profile 上,两者只写 manifest 并报告 `effect: 'restart'`。 + +`plugins/uninstall` 在组合包已启用时先停用它,删除每一条点名该包模块的用户层行,运行 `pnpm remove`,并忘掉探针记录。 + +### 用户层里的行 + +`plugins/addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`plugins/removeRow` 移除一条插入的行,`plugins/setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。 + +`plugins/dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `pnpmCommand` | `pnpm` | 可执行文件,与 `dsh plugin` 命令一样经 `PATH` 解析。 | +| `installTimeoutMs` | `600000` | 单次安装或移除运行的上限。 | +| `probeTimeoutMs` | `20000` | 单次包探测的上限。 | +| `installLogTailBytes` | `16384` | 安装失败时报告多少尾部输出。 | + +----- + + +## 理解实现 + +
+实现内幕——点击展开 + +### 一份 manifest,两个写入者 + +每次变更都重新读取 profile manifest,并通过 `dsh plugin` 命令所用的同一组 app-boot 助手(`reconcileInstalledBundles`、`enableBundle`、`disableBundle`)写回,因此 CLI 与管理器对这个文件永远不会有分歧。`dependencies` 记录装了什么;`dsh.profile.bundles` 记录启用了什么。 + +### pnpm 经 `node:child_process` 运行 + +subprocess seam 会清洗形似密钥的变量且没有 shell 模式,而 pnpm 既需要用户的 registry、代理与鉴权设置,在 Windows 上又需要解析其 `.cmd` shim 的 shell。于是管理器按 CLI 的方式生成 pnpm:带父进程环境、Windows 上开 `shell`,并自己流式读取子进程的输出。 + +### 重试即先停用再启用 + +Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离行在普通的重新组合中不会重新启动。重试把组合包移出层列表再放回去:两次重新组合,manifest 首尾如一。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `PluginManager`:`pluginManager` 服务、`plugins` Remote 方法、pnpm 运行器与视图折叠 | +| [`src/types.ts`](src/types.ts) | wire 载荷、`plugins/changed` 与 `plugins/install-log` 事件,以及 `plugins/*` 失败码 | +| — | 不发布运行时不变量伴随件;每份视图都在每次调用时从 manifest、探针缓存与 Loader 持有的状态折叠而来。 | + +Typert 生成 `./typert` 与 `./remote` 暴露的宿主与客户端 Remote 工件。 + +
+ +----- + + +## 进一步探索 + +当管理器的契约还不够时读这些:它驱动的运行时、它编辑的文件,以及渲染它的界面。 + +- [App boot](../../boot/app-boot/README.zh.md)——profile runtime、外部组合包隔离与包探针。 +- [补丁文件](../../util/patch-file/README.zh.md)——用户层的行如何写入。 +- [Agent presets](../../preset/agent-presets/README.zh.md)——preset 目标所写的每预设用户层。 +- [插件清单](../plugin-inventory/README.zh.md)——本服务旁边的行级只读投影。 + +----- + + +## 模型体验 + +无,宿主侧的插件管理器不注册任何面向模型的东西;它组合出的行各自拥有自己做出的注册。 + +#### KV Cache 影响 + +无;本包既不组装也不发送 provider 请求。 + +## 已知限制与延期工作 + + + + +这些限制界定管理器不会为客户端做什么。它们是当前包的约束,不是任务清单。 + +- **更新已加载的包需要重启**——Node 按 URL 缓存 ESM 模块,hoisted 安装下路径不变;经 `install` 做的 `pnpm update` 改写了文件,但运行中的树在进程重启前一直用旧模块。 +- **依赖检测止于注入**——注册型依赖(工具、LLM 适配器)没有 `inject` 边,因此 `dependents` 无法点名只读取该包所注册内容的行。 +- **preset 的行不在线组合**——管理器写入 preset 的层;之后创建的会话组合它,已在运行的会话保持其代际。 +- **尚无 `engines.dsh` 检查**——该范围只被报告,不对运行中的 harness 版本强制执行。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/host/plugin-manager/package.json b/packages/host/plugin-manager/package.json new file mode 100644 index 0000000000..662f48a2e1 --- /dev/null +++ b/packages/host/plugin-manager/package.json @@ -0,0 +1,75 @@ +{ + "name": "@deepseek-ai/dsh-host-plugin-manager", + "description": "Plugin management over the booted profile: install, enable, disable, and retry bundles, add rows to the global or a preset's user layer, and report each package's state through the plugins Remote", + "version": "0.1.2-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/plugin-manager" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-patch-file": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + } + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-patch-file": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^" + } +} diff --git a/packages/host/plugin-manager/src/index.ts b/packages/host/plugin-manager/src/index.ts new file mode 100644 index 0000000000..ae1026e56d --- /dev/null +++ b/packages/host/plugin-manager/src/index.ts @@ -0,0 +1,858 @@ +/** + * Plugin management over the booted profile. + * + * The profile launcher composes the host tree from bundle layers and user + * patch files and, since `profileRuntime`, can recompose it while running. + * This service is the one place that changes what those layers are: it runs + * pnpm in the profile directory to install or remove a package, moves a + * bundle in and out of the profile's layer list, recomposes the tree through + * the runtime and reports the outcome, retries a bundle whose rows failed, + * adds and removes rows in the profile's global user layer or one agent + * preset's, and folds the manifest, the probe record, and the live tree into + * one view per package for the `plugins` Remote. Every change is followed by + * `plugins/changed`; an install run streams pnpm's output as + * `plugins/install-log`. + * @module @deepseek-ai/dsh-host-plugin-manager + */ + +import { spawn as spawnChild, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis' +import type { Entry } from '@deepseek-ai/cordis-plugin-loader' +import z from '@deepseek-ai/schemastery' +import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets' +import { + bundleGroupId, + disableBundle, + enableBundle, + externalRowId, + healProfilesModuleFallback, + layerTrust, + loadProfile, + PLUGIN_PROBE_DIR, + probePackage, + readProbeCache, + readProfileManifest, + reconcileInstalledBundles, + recordContainedStates, + resolveBundleDir, + writeProbeCache, + type BundleStage, + type PluginProbe, + type ProfileManifest, + type ProfileRuntime, +} from '@deepseek-ai/dsh-app-boot' +import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-patch-file' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +// Typert-generated ./typert and ./remote artifacts import Zod at runtime. +import type {} from 'zod' +import type { + PluginChangeReason, + PluginDependents, + PluginEnableResult, + PluginInstallResult, + PluginPackageAddableView, + PluginPackageRowView, + PluginPackageStatus, + PluginPackageView, + PluginRowAddition, + PluginRowPhase, + PluginRowReference, + PluginRowTarget, + PluginServiceDependent, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Plugin management over the booted profile; mounted by the web bundle. */ + pluginManager: PluginManager + } +} + +/** Diagnostic prefix on errors this service raises through app-boot helpers. */ +const NAME = 'plugin-manager' + +/** The message of a thrown value; a non-Error keeps its text. */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** The profile's installed dependencies by name; a hand-written manifest may omit the field. */ +function dependenciesOf(manifest: ProfileManifest): Record { + return manifest.dependencies ?? {} +} + +/** The profile's enabled layer list; a hand-written manifest may omit the section. */ +function bundlesOf(manifest: ProfileManifest): readonly string[] { + return manifest.dsh?.profile?.bundles ?? [] +} + +/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */ +export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand: string + /** Bound on one install or remove run, in milliseconds. */ + installTimeoutMs: number + /** Bound on one package probe, in milliseconds. */ + probeTimeoutMs: number + /** How many trailing bytes of an install run's output an install failure reports. */ + installLogTailBytes: number +} + +/** Runtime mirror: FiberState is a cross-package const enum. */ +const FIBER_STATE = { + PENDING: 0 as FiberState.PENDING, + LOADING: 1 as FiberState.LOADING, + ACTIVE: 2 as FiberState.ACTIVE, + FAILED: 3 as FiberState.FAILED, + DISPOSED: 4 as FiberState.DISPOSED, + UNLOADING: 5 as FiberState.UNLOADING, +} as const + +/** Complete projection of Cordis Fiber states onto the row phase vocabulary. */ +const ROW_PHASE = { + [FIBER_STATE.PENDING]: 'pending', + [FIBER_STATE.LOADING]: 'loading', + [FIBER_STATE.ACTIVE]: 'active', + [FIBER_STATE.FAILED]: 'failed', + [FIBER_STATE.DISPOSED]: null, + [FIBER_STATE.UNLOADING]: 'unloading', +} as const satisfies Record + +/** The spawn function, replaceable in tests so no pnpm runs. */ +export type SpawnLike = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess + +/** Test seams: the child spawner and the package probe. */ +export interface PluginManagerInternals { + spawn?: SpawnLike + probe?: typeof probePackage +} + +/** The installed package's manifest slice the view reads. */ +type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } } + +/** Fields of one row's provenance a package view needs. */ +interface RowFacts { + readonly entry: Entry + readonly rowId: string +} + +/** + * The `pluginManager` service and the `plugins` Remote. + * + * Every method that changes the profile reads the manifest afresh and writes + * it through the app-boot helpers the `dsh plugin` command uses, so the CLI + * and the manager never disagree on the file. The profile runtime is + * resolved per call: a composition without it (a test, a launcher other than + * the profile launcher) still mounts this service, and every call then + * reports `plugins/unavailable` rather than the service failing to start. + */ +export class PluginManager extends TypertRemoteService { + static inject = ['loader'] + + static Config: z = z.object({ + pnpmCommand: z.string().default('pnpm'), + installTimeoutMs: z.number().min(1_000).default(600_000), + probeTimeoutMs: z.number().min(1_000).default(20_000), + installLogTailBytes: z.number().min(256).default(16_384), + }) + + private readonly spawn: SpawnLike + private readonly probeRunner: typeof probePackage + + constructor(ctx: Context, public config: Config, internals: PluginManagerInternals = {}) { + super(ctx, 'pluginManager', { namespace: 'plugins' }) + this.spawn = internals.spawn ?? spawnChild + this.probeRunner = internals.probe ?? probePackage + } + + /** The profile runtime, or the failure a caller without one receives. */ + private runtime(): ProfileRuntime { + const runtime = this.ctx.get('profileRuntime') + if (runtime === undefined) { + throw new RemoteError( + 'plugins/unavailable', + 'plugin-manager: no profile is composed in this process, so there are no plugins to manage', + { reason: 'no profile runtime' }, + ) + } + return runtime + } + + /** + * Every package the profile knows: its template and installed bundles, + * and every other installed dependency. + * @returns one view per package, bundles first in layer order. + */ + @Remote('list') + async list(): Promise { + const runtime = this.runtime() + const manifest = readProfileManifest(NAME, runtime.dir) + const names = [...new Set([...bundlesOf(manifest), ...Object.keys(dependenciesOf(manifest))])] + const views: PluginPackageView[] = [] + for (const name of names) views.push(await this.view(runtime, manifest, name)) + return views + } + + /** Fold one package's manifest, probe, and tree facts into its view. */ + private async view(runtime: ProfileRuntime, manifest: ProfileManifest, name: string): Promise { + const installed = name in dependenciesOf(manifest) + const enabled = bundlesOf(manifest).includes(name) + const layer = runtime.layers.find(candidate => candidate.packageName === name) + const trust = layer?.trust ?? layerTrust(manifest, name) + const liveReload = runtime.patchReload === 'live' + let probe: PluginProbe | undefined + let probeFailure: string | undefined + if (installed) { + try { + probe = await this.probe(runtime, name) + } catch (error) { + probeFailure = messageOf(error) + } + } + const packageManifest = readInstalledManifest(runtime, name) + const stage: BundleStage = layer?.stage + ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime') + const kind = probe?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'library') + const composed = layer !== undefined + const rows = composed ? this.composedRows(runtime, name) : this.probedRows(name, trust, probe) + const status = this.status({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows }) + const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required' + ? 'the profile applies layer changes at its next start' + : status === 'failed' || status === 'partial' + ? rows.find(row => row.failure !== undefined)?.failure?.message + : undefined) + return { + name, + ...optional('version', packageManifest?.version), + ...optional('title', packageManifest?.dsh?.title), + ...optional('description', packageManifest?.description), + kind, + trust, + stage, + installed, + enabled, + status, + ...optional('reason', reason), + ...optional('enginesDsh', probe?.enginesDsh), + cordisSameCopy: probe?.cordisSameCopy ?? null, + rows, + overrides: probe?.overrides ?? [], + addable: (probe?.addable ?? []).map(entry => addableView(name, entry)), + ...optional('probedAt', probe?.checkedAt), + liveReload, + } + } + + /** The package's status, folded from what the view already knows. */ + private status(facts: { + kind: PluginPackageView['kind'] + installed: boolean + enabled: boolean + composed: boolean + liveReload: boolean + probe: PluginProbe | undefined + probeFailure: string | undefined + rows: readonly PluginPackageRowView[] + }): PluginPackageStatus { + if (facts.kind !== 'bundle') return 'plain' + if (facts.installed && (facts.probeFailure !== undefined || facts.probe?.ok === false)) return 'not-enableable' + if (facts.enabled !== facts.composed) return 'restart-required' + if (!facts.enabled) return 'disabled' + const live = facts.rows.filter(row => row.enabled) + if (live.length === 0) return 'running' + const active = live.filter(row => row.phase === 'active').length + if (active === live.length) return 'running' + return active === 0 ? 'failed' : 'partial' + } + + /** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */ + private composedRows(runtime: ProfileRuntime, name: string): PluginPackageRowView[] { + const failures = this.ctx.get('pluginFailures') + const userDisabled = runtime.userDisabledRowIds() + const rows: PluginPackageRowView[] = [] + const listed = new Set() + for (const { entry, rowId } of this.ownedEntries(runtime, name)) { + listed.add(entry.id) + const origin = runtime.originOf(rowId) + const failure = failures?.get(entry.id) + rows.push({ + entryId: entry.id, + ...optional('originalId', origin?.originalId), + moduleName: entry.options.name, + enabled: !entry.disabled, + ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {}, + phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state], + ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } }, + }) + } + /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */ + for (const failure of failures?.list() ?? []) { + if (listed.has(failure.entryId)) continue + const origin = runtime.originOf(failure.rowId) + if (origin?.packageName !== name) continue + rows.push({ + entryId: failure.entryId, + ...optional('originalId', origin.originalId), + moduleName: failure.moduleName, + enabled: true, + phase: 'failed', + failure: { stage: failure.stage, message: failure.message }, + }) + } + return rows + } + + /** The non-group tree entries a package's layer inserted. */ + private ownedEntries(runtime: ProfileRuntime, name: string): RowFacts[] { + const found: RowFacts[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.options.group) continue + const rowId = entry.options.id + if (runtime.originOf(rowId)?.packageName !== name) continue + found.push({ entry, rowId }) + } + return found + } + + /** The rows a bundle would contribute, from its probe record, when it is not composed. */ + private probedRows(name: string, trust: PluginPackageView['trust'], probe: PluginProbe | undefined): PluginPackageRowView[] { + return (probe?.rows ?? []).map(row => ({ + entryId: row.id === undefined ? row.name : trust === 'external' ? externalRowId(name, row.id) : row.id, + ...optional('originalId', row.id), + moduleName: row.name, + enabled: !row.gated, + ...row.gated ? { disabledBy: 'composition' as const } : {}, + phase: null, + })) + } + + /** The package's probe record, from the profile cache or a fresh probe written to it. */ + private async probe(runtime: ProfileRuntime, name: string): Promise { + const version = readInstalledManifest(runtime, name)?.version + const cached = readProbeCache(runtime.dir, name, version) + if (cached !== undefined) return cached + const probe = await this.probeRunner({ + binName: NAME, + profileDir: runtime.dir, + installAnchor: runtime.installAnchor, + packageName: name, + timeoutMs: this.config.probeTimeoutMs, + }) + writeProbeCache(runtime.dir, probe) + return probe + } + + /** + * Install a package into the profile with pnpm, probe it, and leave it + * disabled unless asked otherwise. The run's output streams as + * `plugins/install-log` chunks carrying the returned `jobId`. + * @param spec - what to install, in pnpm's own vocabulary: a registry + * name, a `github:` or git URL, a tarball, or an absolute path. + * @param options - `enable` puts every newly installed bundle into the layer list at once. + * @returns what the run installed and enabled. + * @throws {RemoteError} `plugins/install-failed` when pnpm exits non-zero + * or the run times out, `plugins/enable-failed` when enabling was asked + * for and the tree rejected the bundle. + */ + @Remote('install') + async install(spec: string, options?: { enable?: boolean }): Promise { + const runtime = this.runtime() + if (spec.trim().length === 0) { + throw new RemoteError('gateway/bad-request', 'plugin-manager: the package spec must not be empty', {}) + } + const before = readProfileManifest(NAME, runtime.dir) + const jobId = await this.runPnpm(runtime, ['add', spec], spec) + const outcome = reconcileInstalledBundles(NAME, runtime.dir, runtime.installAnchor, before, { autoEnable: false }) + const after = readProfileManifest(NAME, runtime.dir) + const installed = Object.keys(dependenciesOf(after)).filter(name => !(name in dependenciesOf(before))) + await healProfilesModuleFallback({ installAnchor: runtime.installAnchor, profile: runtime.current }) + for (const name of installed) { + // A probe that cannot run leaves no record; the view reports the + // package as not enableable with the probe's own reason. + try { + await this.probe(runtime, name) + } catch { + // The failure is re-derived on every list and shown there. + } + } + const enabled: string[] = [] + if (options?.enable === true) { + for (const name of outcome.installedOnly) { + await this.enable(name) + enabled.push(name) + } + } + this.changed('install') + return { + installed, + enabled, + installedOnly: outcome.installedOnly.filter(name => !enabled.includes(name)), + plain: outcome.plain, + jobId, + } + } + + /** + * Remove a package from the profile: disable it when enabled, drop every + * user-layer row that names it, run `pnpm remove`, and forget its probe. + * @param packageName - the installed dependency to remove. + * @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero. + */ + @Remote('uninstall') + async uninstall(packageName: string): Promise { + const runtime = this.runtime() + this.assertInstalled(runtime, packageName) + const references = await this.rowReferences(runtime, packageName) + if (bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) { + await this.disable(packageName) + } + for (const reference of references) { + await this.editLayer(runtime, reference.target, (document) => { document.removeInsert(reference.rowId) }) + } + const before = readProfileManifest(NAME, runtime.dir) + await this.runPnpm(runtime, ['remove', packageName], packageName) + reconcileInstalledBundles(NAME, runtime.dir, runtime.installAnchor, before, { autoEnable: false }) + rmSync(join(runtime.dir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`), { force: true }) + if (references.some(reference => reference.target.kind === 'global') && runtime.patchReload === 'live') { + await runtime.recompose() + } + this.changed('uninstall', packageName) + } + + /** + * Put an installed bundle into the layer list and, on a live profile, + * recompose the tree with it. A rejected recomposition restores the list + * and reports the tree's reason; the tree that was running keeps running. + * @param packageName - the installed bundle. + * @returns whether the list changed and whether the change is live. + * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable` + * for a package that declares no bundle or whose probe refused it, or + * `plugins/enable-failed`. + */ + @Remote('enable') + async enable(packageName: string): Promise { + const runtime = this.runtime() + this.assertInstalled(runtime, packageName) + const probe = await this.probe(runtime, packageName).catch((error: unknown) => { + const reason = `cannot be probed: ${messageOf(error)}` + throw new RemoteError('plugins/not-enableable', `plugin-manager: ${packageName} ${reason}`, { packageName, reason }) + }) + if (!probe.ok) { + // The probe states a reason with every refusal; the fallback keeps the type total. + /* v8 ignore next */ + const reason = probe.reason ?? 'the probe refused it' + throw new RemoteError('plugins/not-enableable', `plugin-manager: ${packageName} cannot be enabled: ${reason}`, { packageName, reason }) + } + let changed: boolean + try { + changed = enableBundle(NAME, runtime.dir, runtime.installAnchor, packageName) + } catch (error) { + const reason = messageOf(error) + throw new RemoteError('plugins/not-enableable', reason, { packageName, reason }) + } + if (runtime.patchReload !== 'live') { + this.changed('enable', packageName) + return { changed, effect: 'restart' } + } + if (changed) { + try { + // Links for the packages the bundle carries must exist before the + // recomposition imports its rows. + await healProfilesModuleFallback({ + installAnchor: runtime.installAnchor, + profile: loadProfile(NAME, runtime.profileName, runtime.installAnchor, undefined, { userLayer: false }), + }) + await runtime.recompose({ reloadBundles: true }) + // The boot audit does not run again; record what the bundle's rows + // came to, so the list shows a waiting or failed row with its reason. + await recordContainedStates(this.ctx) + } catch (error) { + const reason = messageOf(error) + disableBundle(NAME, runtime.dir, packageName) + // The tree without the bundle is the tree that was running a moment + // ago; recomposing back to it is the update that already succeeded. + /* v8 ignore next */ + await runtime.recompose({ reloadBundles: true }).catch(() => undefined) + throw new RemoteError( + 'plugins/enable-failed', + `plugin-manager: enabling ${packageName} failed and the layer list was restored: ${reason}`, + { packageName, reason }, + { cause: error }, + ) + } + } + this.changed('enable', packageName) + return { changed, effect: 'live' } + } + + /** + * Take a bundle out of the layer list and, on a live profile, recompose + * the tree without it. + * @param packageName - the enabled bundle. + * @returns whether the list changed and whether the change is live. + * @throws {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency. + */ + @Remote('disable') + async disable(packageName: string): Promise { + const runtime = this.runtime() + let changed: boolean + try { + changed = disableBundle(NAME, runtime.dir, packageName) + } catch (error) { + throw new RemoteError('gateway/bad-request', messageOf(error), {}) + } + if (runtime.patchReload !== 'live') { + this.changed('disable', packageName) + return { changed, effect: 'restart' } + } + if (changed) await runtime.recompose({ reloadBundles: true }) + this.changed('disable', packageName) + return { changed, effect: 'live' } + } + + /** + * Compose an enabled bundle again from scratch: its group leaves the tree + * and returns, so rows that failed at boot get another start. + * @param packageName - the enabled bundle. + * @returns the enable outcome of the second step. + * @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures. + */ + @Remote('retry') + async retry(packageName: string): Promise { + const runtime = this.runtime() + if (!bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) { + throw new RemoteError('gateway/bad-request', `plugin-manager: ${packageName} is not enabled`, {}) + } + await this.disable(packageName) + const result = await this.enable(packageName) + this.changed('retry', packageName) + return result + } + + /** + * Add a row naming one of the package's modules to a user layer: the + * profile's global `cordis.patch.yml`, or an agent preset's. + * @param packageName - the installed package. + * @param target - which layer. + * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`), + * `id` overrides the derived row id, `config` overrides the declared default. + * @returns where the row landed. + * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable` + * when the module is not one the probe found addable, `plugins/row-conflict`, + * or `plugins/unavailable` for a preset target without a roster. + */ + @Remote('addRow') + async addRow( + packageName: string, + target: PluginRowTarget, + options?: { module?: string; id?: string; config?: JsonValue }, + ): Promise { + const runtime = this.runtime() + this.assertInstalled(runtime, packageName) + const probe = await this.probe(runtime, packageName) + const declared = options?.module ?? '.' + const addable = declared === '.' && probe.kind === 'plugin' + ? { ok: true, config: undefined, error: undefined } + : probe.addable.find(entry => entry.name === declared) + if (addable === undefined || !addable.ok) { + const reason = addable?.error ?? `${declared} is not a module ${packageName} declares addable` + throw new RemoteError('plugins/not-enableable', `plugin-manager: ${reason}`, { packageName, reason }) + } + const moduleName = moduleSpecifier(packageName, declared) + const rowId = options?.id ?? derivedRowId(packageName, declared) + const row: PatchRow = { id: rowId, name: moduleName, config: options?.config ?? addable.config ?? {} } + if (await this.rowExists(runtime, target, rowId)) { + throw new RemoteError('plugins/row-conflict', `plugin-manager: a row ${JSON.stringify(rowId)} already exists`, { rowId, target }) + } + const file = await this.editLayer(runtime, target, (document) => { document.appendInsert(row) }) + this.changed('row', packageName) + return { target, rowId, file } + } + + /** + * Remove a row a user layer inserted. + * @param target - which layer. + * @param rowId - the inserted row's id. + * @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row. + */ + @Remote('removeRow') + async removeRow(target: PluginRowTarget, rowId: string): Promise { + const runtime = this.runtime() + // A holder rather than a `let`: the assignment happens inside the edit + // callback, which control-flow narrowing does not see. + const outcome = { removed: false } + await this.editLayer(runtime, target, (document) => { outcome.removed = document.removeInsert(rowId) }) + if (!outcome.removed) { + throw new RemoteError('gateway/bad-request', `plugin-manager: the layer inserts no row ${JSON.stringify(rowId)}`, {}) + } + this.changed('row') + } + + /** + * Switch one row off or on in a user layer. Deny-only: `true` writes + * `disabled: true` for the row, `false` removes that key, so a bundle's + * own `!!js` gate is restored rather than overridden. + * @param target - which layer. + * @param rowId - the row's id as the composition declares it. + * @param disabled - whether the layer should switch the row off. + */ + @Remote('setRowDisabled') + async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise { + const runtime = this.runtime() + await this.editLayer(runtime, target, (document) => { + if (disabled) document.setRowField(rowId, 'disabled', true) + else document.deleteRowField(rowId, 'disabled') + }) + this.changed('row') + } + + /** + * What disabling or removing a package would strand: services its rows + * provide that rows outside it inject, and user-layer rows naming its modules. + * @param packageName - the package. + * @returns the dependents. + */ + @Remote('dependents') + async dependents(packageName: string): Promise { + const runtime = this.runtime() + const owned = this.ownedEntries(runtime, packageName) + const ownedIds = new Set(owned.map(({ entry }) => entry.id)) + const store = this.ctx.reflect.store + const services: PluginServiceDependent[] = [] + for (const { entry } of owned) { + const fiber = entry.fiber + if (fiber === undefined) continue + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ + if (impl === undefined || !withinFiber(impl.fiber, fiber)) continue + const injectedBy: string[] = [] + for (const other of this.ctx.loader.entries()) { + if (ownedIds.has(other.id) || other.fiber === undefined) continue + if (impl.name in other.fiber.inject) injectedBy.push(other.id) + } + if (injectedBy.length > 0) services.push({ service: impl.name, providedBy: entry.id, injectedBy }) + } + } + return { services, references: await this.rowReferences(runtime, packageName) } + } + + /** Every user-layer row naming the package or one of its subpaths. */ + private async rowReferences(runtime: ProfileRuntime, packageName: string): Promise { + const found: PluginRowReference[] = [] + const names = (target: PluginRowTarget, rows: readonly PatchRow[]): void => { + for (const row of rows) { + // A group's config is a row list once the patch parser accepted the file. + /* v8 ignore next */ + if (row.group === true) names(target, (row.config ?? []) as PatchRow[]) + if (typeof row.id !== 'string') continue + if (row.name === packageName || row.name.startsWith(`${packageName}/`)) { + found.push({ target, rowId: row.id, moduleName: row.name }) + } + } + } + const scan = async (target: PluginRowTarget, file: string): Promise => { + let patches + try { + patches = await readPatchListFile(NAME, file, 'patches') + } catch { + // An unreadable layer names nothing this call can act on; the + // launcher and the roster report it as their own failure. + return + } + for (const patch of patches ?? []) if (patch.insert !== undefined) names(target, patch.insert) + } + await scan({ kind: 'global' }, runtime.patchPath) + const presets = this.ctx.get('agentPresets') + if (presets !== undefined) { + for (const preset of await presets.list()) { + if (preset.overlayPath !== undefined) await scan({ kind: 'preset', preset: preset.id }, preset.overlayPath) + } + } + return found + } + + /** Whether the target layer's composition already carries a row with `rowId`. */ + private async rowExists(runtime: ProfileRuntime, target: PluginRowTarget, rowId: string): Promise { + if (target.kind === 'global') { + return [...this.ctx.loader.entries()].some(entry => entry.options.id === rowId) + || (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId)) + } + const presets = this.presets() + const composition = (await presets.compositionInventory()).find(candidate => candidate.id === target.preset) + return composition?.rows.some(row => row.entryId === rowId) ?? false + } + + /** The roster, or the failure a preset target without one receives. */ + private presets(): AgentPresets { + const presets = this.ctx.get('agentPresets') + if (presets === undefined) { + throw new RemoteError('plugins/unavailable', 'plugin-manager: no agent-preset roster is composed', { reason: 'no roster' }) + } + return presets + } + + /** Edit one user layer file and, for the live global layer, recompose. */ + private async editLayer( + runtime: ProfileRuntime, + target: PluginRowTarget, + mutate: Parameters[1], + ): Promise { + const file = target.kind === 'global' ? runtime.patchPath : await this.presets().overlayPathFor(target.preset) + await mutatePatchFile(file, mutate, { binName: NAME, mode: 0o600, dirMode: 0o700 }) + // The profile launcher's watcher reapplies the global file on its own; + // recomposing here makes the change visible to this call's caller before + // it returns, and the watcher's later pass composes the same text. + if (target.kind === 'global' && runtime.patchReload === 'live') await runtime.recompose() + return file + } + + private assertInstalled(runtime: ProfileRuntime, packageName: string): void { + if (packageName in dependenciesOf(readProfileManifest(NAME, runtime.dir))) return + throw new RemoteError( + 'plugins/not-installed', + `plugin-manager: ${packageName} is not installed in profile ${runtime.profileName}`, + { packageName }, + ) + } + + /** + * Run one pnpm command in the profile directory, streaming its output as + * `plugins/install-log` chunks. + * @returns the run's job id. + * @throws {RemoteError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout. + */ + private async runPnpm(runtime: ProfileRuntime, args: readonly string[], spec: string): Promise { + const jobId = randomUUID() + const tail: string[] = [] + let tailBytes = 0 + const record = (stream: 'stdout' | 'stderr', text: string): void => { + tail.push(text) + tailBytes += Buffer.byteLength(text) + while (tailBytes > this.config.installLogTailBytes && tail.length > 1) { + tailBytes -= Buffer.byteLength(tail.shift() as string) + } + this.ctx.emit('plugins/install-log', { jobId, spec, stream, text }) + } + // Windows resolves pnpm through its .cmd shim, which spawn() refuses + // without a shell since the CVE-2024-27980 hardening. The parent + // environment is passed whole, as the `dsh plugin` command does: pnpm + // needs the user's registry, proxy, and auth settings. + const child = this.spawn(this.config.pnpmCommand, args, { + cwd: runtime.dir, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + env: process.env, + }) + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', (text: string) => { record('stdout', text) }) + child.stderr?.on('data', (text: string) => { record('stderr', text) }) + const exitCode = await new Promise((resolve, reject) => { + let settled = false + const settle = (outcome: () => void): void => { + if (settled) return + settled = true + clearTimeout(timer) + outcome() + } + const timer = setTimeout(() => { + child.kill('SIGKILL') + settle(() => { reject(new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`)) }) + }, this.config.installTimeoutMs) + child.on('error', (error) => { settle(() => { reject(error) }) }) + child.on('close', (code) => { settle(() => { resolve(code) }) }) + }).catch((error: unknown) => { + const message = messageOf(error) + record('stderr', `${message}\n`) + this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stderr', text: '', exitCode: null }) + throw new RemoteError('plugins/install-failed', `plugin-manager: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error }) + }) + this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stdout', text: '', exitCode }) + if (exitCode !== 0) { + throw new RemoteError( + 'plugins/install-failed', + `plugin-manager: pnpm ${args.join(' ')} exited with ${String(exitCode)} in ${runtime.dir}`, + { spec, exitCode, log: tail.join('') }, + ) + } + return jobId + } + + private changed(reason: PluginChangeReason, packageName?: string): void { + this.ctx.emit('plugins/changed', { reason, ...optional('packageName', packageName) }) + } +} + +/** + * Whether `fiber` is `root` itself or sits anywhere inside its subtree: a + * service is provided by a fiber under the row's root fiber (a `ctx.inject` + * continuation, an `isolate` realm). An entry holds the thenable wrapper + * `ctx.plugin()` returns, which is never identical to the fiber in a parent + * chain, so fibers of one runtime compare by `uid`. + */ +function withinFiber(fiber: Fiber, root: Fiber): boolean { + let current = fiber + for (;;) { + if (current === root || (current.uid !== null && current.uid === root.uid)) return true + const parent = current.parent.fiber + if (parent === current) return false + current = parent + } +} + +/** One optional field, present only when its value is. */ +function optional(key: K, value: V | undefined): { [P in K]?: V } { + return value === undefined ? {} : { [key]: value } as { [P in K]?: V } +} + +/** The installed package's manifest, or undefined when it cannot be resolved from the profile. */ +function readInstalledManifest(runtime: ProfileRuntime, name: string): InstalledManifest | undefined { + let dir: string + try { + dir = resolveBundleDir(NAME, name, runtime.installAnchor, runtime.dir) + } catch { + // A dependency pnpm removed underneath the manifest, or one that never + // materialized; the view reports what the manifest still says. + return undefined + } + // resolveBundleDir answers only a directory holding a package.json. + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as InstalledManifest +} + +/** The row `name` for one declared addable module. */ +function moduleSpecifier(packageName: string, declared: string): string { + if (declared === '.') return packageName + return `${packageName}/${declared.replace(/^\.\//, '')}` +} + +/** The row id derived from a package name and module: the unscoped name, then the subpath. */ +function derivedRowId(packageName: string, declared: string): string { + const base = packageName.replace(/^@/, '') + return declared === '.' ? base : `${base}/${declared.replace(/^\.\//, '')}` +} + +/** The wire view of one probed addable module. */ +function addableView(packageName: string, entry: PluginProbe['addable'][number]): PluginPackageAddableView { + return { + moduleName: moduleSpecifier(packageName, entry.name), + declaredName: entry.name, + ...optional('title', entry.title), + // The probe read both from JSON: a manifest field and a child's report. + ...optional('config', entry.config as JsonValue | undefined), + ok: entry.ok, + ...optional('error', entry.error), + ...optional('configSchema', entry.configSchema as JsonValue | undefined), + } +} + +/** Group id of an isolated bundle, for callers that address its rows in the tree. */ +export { bundleGroupId } + +export default PluginManager diff --git a/packages/host/plugin-manager/src/types.ts b/packages/host/plugin-manager/src/types.ts new file mode 100644 index 0000000000..a11e734e91 --- /dev/null +++ b/packages/host/plugin-manager/src/types.ts @@ -0,0 +1,222 @@ +/** + * Client-safe payloads, events, and failure codes of the plugin manager. + * Every value here crosses the Typert wire as lossless JSON. + * @module @deepseek-ai/dsh-host-plugin-manager/types + */ + +import type { JsonValue } from '@deepseek-ai/dsh-util-values' + +/** What an installed package is: a bundle layer, a plugin module, or a plain library. */ +export type PluginPackageKind = 'bundle' | 'plugin' | 'library' + +/** Who supplied a package: the installation's own bundles, or a dependency the user installed. */ +export type PluginPackageTrust = 'builtin' | 'external' + +/** When a bundle's rows mount: isolated at `runtime` (the default), or with the built-in rows at `boot`. */ +export type PluginPackageStage = 'boot' | 'runtime' + +/** + * The state one package is in, folded from the profile manifest, the probe + * record, and the live tree: + * + * - `running`: enabled and every row active; + * - `partial`: enabled with at least one row failed or waiting; + * - `failed`: enabled and no row active; + * - `disabled`: installed and not in the layer list; + * - `not-enableable`: installed but the probe refused it (import failure, foreign cordis copy); + * - `restart-required`: its manifest state and the live tree disagree, which a profile without live reload resolves at the next start; + * - `plain`: a library or plugin module, which is added to a composition rather than enabled. + */ +export type PluginPackageStatus = + | 'running' + | 'partial' + | 'failed' + | 'disabled' + | 'not-enableable' + | 'restart-required' + | 'plain' + +/** Lifecycle phase of a row's root fiber; null when the row has no live fiber. */ +export type PluginRowPhase = 'pending' | 'loading' | 'active' | 'failed' | 'unloading' | null + +/** One row a bundle contributes to the host tree, as the tree runs it. */ +export interface PluginPackageRowView { + /** The row's tree-wide id, prefixed for an isolated bundle. */ + readonly entryId: string + /** The id the bundle's own patch declared, when the launcher prefixed it. */ + readonly originalId?: string + /** Module specifier the row names. */ + readonly moduleName: string + /** Effective enablement, including a disabled owning group. */ + readonly enabled: boolean + /** Present exactly when `enabled` is false. */ + readonly disabledBy?: 'user' | 'composition' + /** Root-fiber phase, or null when the row has no live fiber. */ + readonly phase: PluginRowPhase + /** The recorded startup failure of an isolated row, when one is recorded. */ + readonly failure?: { readonly stage: string; readonly message: string } +} + +/** One agent-plane module a package declares addable to a composition. */ +export interface PluginPackageAddableView { + /** The module as it is named in a row: the bare package for `.`, else `/`. */ + readonly moduleName: string + /** The `dsh.plugins[].name` the package declared. */ + readonly declaredName: string + /** Display title, when declared. */ + readonly title?: string + /** Default row config, when declared. */ + readonly config?: JsonValue + /** Whether the probe imported the module and found a plugin. */ + readonly ok: boolean + /** The probe's import failure, when `ok` is false. */ + readonly error?: string + /** The module's `Config` schema envelope, when it declares one. */ + readonly configSchema?: JsonValue +} + +/** One package as the manager sees it. */ +export interface PluginPackageView { + /** The package name; also the row-id prefix and group id of an isolated bundle. */ + readonly name: string + /** The installed version, when the manifest declares one. */ + readonly version?: string + /** Display title from `dsh.title`, when declared. */ + readonly title?: string + /** Free text from the manifest. */ + readonly description?: string + readonly kind: PluginPackageKind + readonly trust: PluginPackageTrust + readonly stage: PluginPackageStage + /** Whether the package is a profile dependency (installed by `dsh plugin add` or the manager). */ + readonly installed: boolean + /** Whether the bundle is in the profile's layer list. */ + readonly enabled: boolean + readonly status: PluginPackageStatus + /** Why the package cannot be enabled, or why it is not running, when the status calls for one. */ + readonly reason?: string + /** The harness version range the package declares in `engines.dsh`. */ + readonly enginesDsh?: string + /** Whether the package shares the harness's cordis copy; null when the probe could not tell. */ + readonly cordisSameCopy: boolean | null + /** The rows the bundle contributes, from the live tree when enabled, else from the probe. */ + readonly rows: readonly PluginPackageRowView[] + /** Ids of built-in rows the bundle's patch overrides. */ + readonly overrides: readonly string[] + /** Agent-plane modules the package declares addable. */ + readonly addable: readonly PluginPackageAddableView[] + /** ISO time of the probe record this view was folded from, when one exists. */ + readonly probedAt?: string + /** Whether the profile applies user patch files while running; false means changes wait for a restart. */ + readonly liveReload: boolean +} + +/** Where a composition row is added or edited: the profile's global user layer, or one preset's. */ +export type PluginRowTarget = + | { readonly kind: 'global' } + | { readonly kind: 'preset'; readonly preset: string } + +/** What one install run changed. */ +export interface PluginInstallResult { + /** Dependencies present after the run and absent before it, by name. */ + readonly installed: readonly string[] + /** Bundles newly enabled, when the caller asked for it. */ + readonly enabled: readonly string[] + /** Newly installed bundles left disabled. */ + readonly installedOnly: readonly string[] + /** Newly installed dependencies that declare no bundle. */ + readonly plain: readonly string[] + /** The identifier the run's `plugins/install-log` chunks carried. */ + readonly jobId: string +} + +/** What enabling a package did. */ +export interface PluginEnableResult { + /** Whether the layer list changed. */ + readonly changed: boolean + /** `live` when the tree was recomposed, `restart` when the profile applies changes at its next start. */ + readonly effect: 'live' | 'restart' +} + +/** One row outside a package that injects a service one of its rows provides. */ +export interface PluginServiceDependent { + /** The service name. */ + readonly service: string + /** The package row providing it. */ + readonly providedBy: string + /** Rows outside the package whose fiber injects it. */ + readonly injectedBy: readonly string[] +} + +/** One user-layer row that names a module of the package. */ +export interface PluginRowReference { + readonly target: PluginRowTarget + readonly rowId: string + readonly moduleName: string +} + +/** What disabling or uninstalling a package would strand. */ +export interface PluginDependents { + readonly services: readonly PluginServiceDependent[] + readonly references: readonly PluginRowReference[] +} + +/** Where a row was added. */ +export interface PluginRowAddition { + readonly target: PluginRowTarget + /** The id the row was given. */ + readonly rowId: string + /** The user layer file the row was written to. */ + readonly file: string +} + +/** Why the manager changed something, for a listener deciding what to refresh. */ +export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row' + +/** One chunk of an install run's output. */ +export interface PluginInstallLogChunk { + /** The run the chunk belongs to. */ + readonly jobId: string + /** The package spec the run installs or removes. */ + readonly spec: string + readonly stream: 'stdout' | 'stderr' + readonly text: string + /** Present on the run's last chunk, with pnpm's exit code (null for a signal). */ + readonly exitCode?: number | null +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No profile runtime is composed, so there is no profile to manage. */ + 'plugins/unavailable': { readonly reason: string } + /** The package is not a profile dependency. */ + 'plugins/not-installed': { readonly packageName: string } + /** The package cannot be enabled or added, with the probe's reason. */ + 'plugins/not-enableable': { readonly packageName: string; readonly reason: string } + /** Enabling composed the bundle and the tree rejected it; the layer list was restored. */ + 'plugins/enable-failed': { readonly packageName: string; readonly reason: string } + /** pnpm exited non-zero. */ + 'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string } + /** The row id is already taken in the target user layer. */ + 'plugins/row-conflict': { readonly rowId: string; readonly target: PluginRowTarget } + } +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * The manager changed what is installed, enabled, or composed. + * @mode emit + * @param change - why, and which package when one is concerned. + */ + 'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void + /** + * One chunk of an install run's output, in order; the last chunk carries the exit code. + * @mode emit + * @param chunk - the chunk. + */ + 'plugins/install-log'(chunk: PluginInstallLogChunk): void + } +} + +export {} diff --git a/packages/host/plugin-manager/tests/plugin-manager.spec.ts b/packages/host/plugin-manager/tests/plugin-manager.spec.ts new file mode 100644 index 0000000000..077b5ac419 --- /dev/null +++ b/packages/host/plugin-manager/tests/plugin-manager.spec.ts @@ -0,0 +1,754 @@ +/** + * The plugin manager over a real profile: a temporary harness home with one + * profile, packages staged the way pnpm leaves them, the host tree booted + * through `boot()` with the profile runtime the launcher provides, and a + * fake pnpm that edits the profile the way the real one does. + */ + +import { EventEmitter } from 'node:events' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { ChildProcess } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Plugin } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { + boot, bundleLayerPatches, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry, + type Profile, +} from '@deepseek-ai/dsh-app-boot' +import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import PluginManager, { + type PluginInstallLogChunk, type PluginManagerInternals, type SpawnLike, +} from '@deepseek-ai/dsh-host-plugin-manager' +import type {} from '@deepseek-ai/dsh-host-plugin-manager/types' + +const NAME = 'dsh-test' + +/** A complete manager config: the schema fills defaults at load, the type does not. */ +function managerConfig(overrides: Partial = {}): PluginManager['config'] { + return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384, ...overrides } +} + +const contexts: Context[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +/** Builtins the staged bundles name; registered on every boot. */ +const good: Plugin.Function = () => {} +const throws: Plugin.Function = () => { throw new Error('boom at apply') } +let flakyCalls = 0 +const flaky: Plugin.Function = () => { + flakyCalls += 1 + if (flakyCalls === 1) throw new Error('flaky first start') +} +const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('fixtureSvc', { ready: true })) } +const lonelyProvider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('lonelySvc', { ready: true })) } +const consumer: Plugin.Object = { inject: ['fixtureSvc'], apply() {} } +const prepare = (ctx: Context): void => { + ctx.loader.builtins.good = good + ctx.loader.builtins.throws = throws + ctx.loader.builtins.flaky = flaky + ctx.loader.builtins.provider = provider + ctx.loader.builtins['lonely-provider'] = lonelyProvider + ctx.loader.builtins.consumer = consumer +} + +interface StagedHome { + home: string + profileDir: string + anchor: string +} + +/** A harness home with one empty live profile and an install anchor that carries nothing. */ +async function stageHome(patchReload: 'live' | 'startup' = 'live'): Promise { + const home = await mkdtemp(join(tmpdir(), 'dsh-plugin-manager-')) + const profileDir = join(home, 'profiles', 'web') + mkdirSync(profileDir, { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-web', private: true, dependencies: {}, dsh: { profile: { bundles: [], patchReload } }, + }, null, 2)) + writeFileSync(join(profileDir, 'cordis.yml'), '[]\n') + const anchorDir = join(home, 'anchor') + mkdirSync(anchorDir, { recursive: true }) + const anchor = join(anchorDir, 'package.json') + writeFileSync(anchor, JSON.stringify({ name: 'dsh-anchor', version: '0.0.0', dependencies: {} })) + return { home, profileDir, anchor } +} + +interface StagedPackage { + /** The bundle patch text; omitted stages a bundle-less package. */ + patch?: string + /** `index.js` text, exported as the package main. */ + main?: string + version?: string + stage?: 'boot' | 'runtime' + plugins?: { name: string; title?: string; config?: unknown }[] + files?: Record +} + +/** Stage one package under the profile's node_modules, the way pnpm leaves it. */ +function stagePackage(profileDir: string, name: string, staged: StagedPackage): void { + const dir = join(profileDir, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name, + version: staged.version ?? '1.0.0', + description: `staged ${name}`, + type: 'module', + ...staged.main === undefined ? {} : { main: 'index.js' }, + dsh: { + title: `Title of ${name}`, + ...staged.patch === undefined ? {} : { bundle: { patch: './cordis.patch.yml', ...staged.stage === undefined ? {} : { stage: staged.stage } } }, + ...staged.plugins === undefined ? {} : { plugins: staged.plugins }, + }, + }, null, 2)) + if (staged.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), staged.patch) + if (staged.main !== undefined) writeFileSync(join(dir, 'index.js'), staged.main) + for (const [file, text] of Object.entries(staged.files ?? {})) { + mkdirSync(join(dir, file, '..'), { recursive: true }) + writeFileSync(join(dir, file), text) + } +} + +/** Add a dependency to the profile manifest, as `pnpm add` does. */ +function addDependency(profileDir: string, name: string, spec = '1.0.0'): void { + const path = join(profileDir, 'package.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record } + manifest.dependencies[name] = spec + writeFileSync(path, JSON.stringify(manifest, null, 2)) +} + +function manifestOf(profileDir: string): { dependencies: Record; dsh: { profile: { bundles: string[] } } } { + return JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ReturnType +} + +/** What the fake pnpm does for one invocation. */ +type PnpmBehavior = (args: readonly string[]) => { code: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown } + +/** A fake `spawn` that runs `behavior` on the next tick and reports through a child-like emitter. */ +function fakePnpm(profileDir: string, behavior: PnpmBehavior, calls: string[][] = []): SpawnLike { + return (command, args, options) => { + calls.push([command, ...args]) + expect(options.cwd).toBe(profileDir) + const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: (signal?: string) => boolean } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + let killed = false + child.kill = () => { killed = true; return true } + setTimeout(() => { + const outcome = behavior(args) + if (outcome.error !== undefined) { + child.emit('error', outcome.error) + return + } + if (outcome.stdout !== undefined) child.stdout.write(outcome.stdout) + if (outcome.stderr !== undefined) child.stderr.write(outcome.stderr) + if (outcome.hang === true) { + // Report the kill the timeout sends, as a real child would. + const poll = setInterval(() => { + if (!killed) return + clearInterval(poll) + child.emit('close', null) + }, 10) + return + } + setTimeout(() => { child.emit('close', outcome.code) }, 5) + }, 5) + return child as unknown as ChildProcess + } +} + +/** The pnpm every install test shares: `add ` stages nothing (the test did) and records the dependency. */ +function recordingPnpm(profileDir: string, calls: string[][] = []): SpawnLike { + return fakePnpm(profileDir, (args) => { + const [verb, target] = args + if (verb === 'add' && target !== undefined) { + addDependency(profileDir, target) + return { code: 0, stdout: `+ ${target} 1.0.0\n` } + } + if (verb === 'remove' && target !== undefined) { + const path = join(profileDir, 'package.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record } + const { [target]: _removed, ...remaining } = manifest.dependencies + writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2)) + rmSync(join(profileDir, 'node_modules', target), { recursive: true, force: true }) + return { code: 0, stdout: `- ${target}\n` } + } + return { code: 1, stderr: 'unexpected pnpm invocation\n' } + }, calls) +} + +interface Booted { + ctx: Context + manager: PluginManager + runtime: ProfileRuntime + changes: { reason: string; packageName?: string }[] + log: PluginInstallLogChunk[] +} + +/** Boot the profile the way the launcher does, then mount the manager over it. */ +async function bootProfile(staged: StagedHome, internals: PluginManagerInternals = {}, config: Partial = {}): Promise { + const load = (): Profile => loadProfile(NAME, 'web', staged.anchor, staged.home) + const composeFor = (profile: Profile): PatchOptions[] => structuredClone([ + ...profile.layers.flatMap(bundleLayerPatches), + ...loadOptionalPatches(NAME, profile.patchPath) ?? [], + ]) + const profile = load() + const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile), prepare) + contexts.push(ctx) + await ctx.plugin(ProfileRuntime, { + profile, + installAnchor: staged.anchor, + loadProfile: load, + compose: composeFor, + rootEntry: () => rootIncludeEntry(ctx), + readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [], + }) + class TestManager extends PluginManager { + constructor(context: Context, managerConfig: PluginManager['config']) { + super(context, managerConfig, internals) + } + } + const changes: Booted['changes'] = [] + const log: PluginInstallLogChunk[] = [] + ctx.on('plugins/changed', (change) => { changes.push(change) }) + ctx.on('plugins/install-log', (chunk) => { log.push(chunk) }) + await ctx.plugin(TestManager, managerConfig(config)) + const manager = ctx.get('pluginManager') + if (manager === undefined) throw new Error('manager did not mount') + return { ctx, manager, runtime: ctx.profileRuntime, changes, log } +} + +const entryIds = (ctx: Context): string[] => [...ctx.loader.entries()].map(entry => entry.id) + +const BUNDLE_ONE_ROW = '- insert:\n - id: hello\n name: cordis:good\n' + +describe('PluginManager', () => { + it('publishes the plugins namespace with one direct method per operation', async () => { + const staged = await stageHome() + const { manager } = await bootProfile(staged) + expect(manager.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' }) + expect(remoteMethods(manager).map(marker => marker.method)).toEqual([ + 'list', 'install', 'uninstall', 'enable', 'disable', 'retry', 'addRow', 'removeRow', 'setRowDisabled', 'dependents', + ]) + }) + + it('reports plugins/unavailable without a profile runtime', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + await ctx.plugin(PluginManager, managerConfig()) + await expect(ctx.get('pluginManager')?.list()).rejects.toMatchObject({ code: 'plugins/unavailable' }) + }) + + describe('list', () => { + it('folds installed, enabled, and probed facts into one view per package', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js', title: 'Extra' }], files: { 'extra.js': 'export const name = "extra"\nexport function apply() {}\n' } }) + stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' }) + stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' }) + addDependency(staged.profileDir, 'ext-bundle') + addDependency(staged.profileDir, 'ext-lib') + addDependency(staged.profileDir, 'ext-plugin') + const { manager } = await bootProfile(staged) + + const views = await manager.list() + + expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled, view.trust])).toEqual([ + ['ext-bundle', 'bundle', 'disabled', true, false, 'external'], + ['ext-lib', 'library', 'plain', true, false, 'external'], + ['ext-plugin', 'plugin', 'plain', true, false, 'external'], + ]) + const bundle = views[0] + expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', stage: 'runtime', liveReload: true }) + // Rows come from the probe while the bundle is not composed, prefixed the way the launcher will prefix them. + expect(bundle?.rows).toEqual([{ entryId: 'ext-bundle/hello', originalId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }]) + expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra', ok: true }]) + expect(bundle?.probedAt).toEqual(expect.any(String) as string) + expect(readProbeCache(staged.profileDir, 'ext-bundle', '1.0.0')?.kind).toBe('bundle') + }) + + it('reads a composed bundle\'s rows from the live tree with their failures', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-mixed', { patch: '- insert:\n - id: ok\n name: cordis:good\n - id: bad\n name: cordis:throws\n' }) + addDependency(staged.profileDir, 'ext-mixed') + const manifest = manifestOf(staged.profileDir) + manifest.dsh.profile.bundles.push('ext-mixed') + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2)) + const { manager } = await bootProfile(staged) + + const [view] = await manager.list() + + expect(view).toMatchObject({ name: 'ext-mixed', status: 'partial', enabled: true }) + expect(view?.reason).toContain('boom at apply') + expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([ + ['include:ext-mixed/ok', 'active', undefined], + ['include:ext-mixed/bad', 'failed', 'apply'], + ]) + }) + + it('reports a package the probe refuses as not enableable, and a stale layer as restart-required', async () => { + const staged = await stageHome('startup') + stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' }) + addDependency(staged.profileDir, 'ext-broken') + stagePackage(staged.profileDir, 'ext-later', { patch: BUNDLE_ONE_ROW }) + addDependency(staged.profileDir, 'ext-later') + const { manager } = await bootProfile(staged) + // Enabled after boot on a startup-reload profile: the manifest says yes, the tree says no. + const manifest = manifestOf(staged.profileDir) + manifest.dsh.profile.bundles.push('ext-later') + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2)) + + const views = await manager.list() + + expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable', reason: expect.stringContaining('no import for you') as string }) + expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false }) + }) + + it('reports a package whose probe cannot run with the probe\'s failure', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW }) + addDependency(staged.profileDir, 'ext-odd') + stagePackage(staged.profileDir, 'ext-refused', { patch: BUNDLE_ONE_ROW }) + addDependency(staged.profileDir, 'ext-refused') + const { manager } = await bootProfile(staged, { + probe: ({ packageName }) => packageName === 'ext-odd' + ? Promise.reject(new Error('probe exploded')) + : Promise.resolve({ packageName, kind: 'bundle', ok: false, reason: 'foreign cordis', cordisSameCopy: false, rows: [], overrides: [], addable: [], checkedAt: 'now' }), + }) + + const views = await manager.list() + + expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'probe exploded', cordisSameCopy: null, rows: [] }) + expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'not-enableable', reason: 'foreign cordis', cordisSameCopy: false }) + await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot be probed: probe exploded' } }) + await expect(manager.enable('ext-refused')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'foreign cordis' } }) + }) + + it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => { + const staged = await stageHome('startup') + stagePackage(staged.profileDir, 'tpl', { patch: BUNDLE_ONE_ROW }) + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl'], patchReload: 'startup' } } })) + const { manager } = await bootProfile(staged) + expect((await manager.list()).map(view => [view.name, view.trust, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'builtin', 'running', false, 1]]) + // A manifest with no dsh section at all knows no bundles. + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' })) + expect(await manager.list()).toEqual([]) + + // Bundles the manifest names after boot: one staged, one that resolves to nothing. + stagePackage(staged.profileDir, 'tpl-later', { patch: BUNDLE_ONE_ROW }) + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl', 'tpl-later', 'ghost'], patchReload: 'startup' } } })) + const views = await manager.list() + expect(views.map(view => [view.name, view.trust, view.kind, view.status])).toEqual([ + ['tpl', 'builtin', 'bundle', 'running'], + ['tpl-later', 'builtin', 'bundle', 'restart-required'], + ['ghost', 'builtin', 'library', 'plain'], + ]) + await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' }) + // Retrying takes the bundle out first, which a template bundle refuses. + await expect(manager.retry('ghost')).rejects.toMatchObject({ code: 'gateway/bad-request' }) + }) + + it('folds disabled, user-disabled, and waiting rows', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-off', { patch: '- insert:\n - id: a\n name: cordis:good\n disabled: true\n - id: b\n name: cordis:good\n' }) + addDependency(staged.profileDir, 'ext-off') + stagePackage(staged.profileDir, 'ext-waiting', { patch: '- insert:\n - id: w\n name: cordis:consumer\n' }) + addDependency(staged.profileDir, 'ext-waiting') + const { manager } = await bootProfile(staged) + await manager.enable('ext-off') + await manager.enable('ext-waiting') + await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true) + + const views = await manager.list() + + const off = views.find(view => view.name === 'ext-off') + expect(off).toMatchObject({ status: 'running' }) + expect(off?.rows.map(row => [row.originalId, row.enabled, row.disabledBy, row.phase])).toEqual([ + ['a', false, 'composition', null], + ['b', false, 'user', null], + ]) + await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true) + const waiting = views.find(view => view.name === 'ext-waiting') + expect(waiting).toMatchObject({ status: 'failed', reason: expect.stringContaining('fixtureSvc') as string }) + expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:ext-waiting/w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })]) + // Another bundle's recorded failure is not this one's row. + expect(off?.rows.some(row => row.failure !== undefined)).toBe(false) + }) + + it('reads anonymous and gated probe rows, unprefixed for a first-party package', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n - name: cordis:good\n - id: gated\n name: cordis:good\n disabled: true\n' }) + addDependency(staged.profileDir, 'ext-anon') + stagePackage(staged.profileDir, 'fp-bundle', { patch: BUNDLE_ONE_ROW }) + addDependency(staged.profileDir, 'fp-bundle') + const manifest = JSON.parse(readFileSync(join(staged.profileDir, 'package.json'), 'utf8')) as { dsh: { profile: Record } } + manifest.dsh.profile.firstParty = ['fp-bundle'] + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest)) + const { manager } = await bootProfile(staged) + + const views = await manager.list() + + expect(views.find(view => view.name === 'ext-anon')?.rows).toEqual([ + { entryId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null }, + { entryId: 'ext-anon/gated', originalId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null }, + ]) + await manager.enable('fp-bundle') + const firstParty = (await manager.list()).find(view => view.name === 'fp-bundle') + expect(firstParty).toMatchObject({ trust: 'builtin', status: 'running' }) + expect(firstParty?.rows.map(row => row.entryId)).toEqual(['include:hello']) + await manager.disable('fp-bundle') + expect((await manager.list()).find(view => view.name === 'fp-bundle')?.rows.map(row => row.entryId)).toEqual(['hello']) + }) + }) + + describe('install', () => { + it('runs pnpm add, records the dependency, probes the package, and leaves it disabled', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW }) + const calls: string[][] = [] + const { manager, changes, log } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) }) + + const result = await manager.install('github:acme/ext-new') + + expect(calls).toEqual([['pnpm', 'add', 'github:acme/ext-new']]) + // The fake pnpm records the spec itself as the dependency name, which + // resolves to nothing: a plain dependency whose probe cannot run. + expect(result).toEqual({ installed: ['github:acme/ext-new'], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string }) + expect(log.map(chunk => [chunk.stream, chunk.text, chunk.exitCode])).toEqual([ + ['stdout', '+ github:acme/ext-new 1.0.0\n', undefined], + ['stdout', '', 0], + ]) + expect(changes).toEqual([{ reason: 'install' }]) + }) + + it('reconciles by the installed name, and enables the new bundle when asked', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW }) + const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) }) + + const result = await manager.install('ext-new', { enable: true }) + + expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] }) + expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new']) + expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:bundle/ext-new', 'include:ext-new/hello'])) + expect(readProbeCache(staged.profileDir, 'ext-new')).toBeDefined() + expect(changes.map(change => change.reason)).toEqual(['enable', 'install']) + expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running') + }) + + it('reports a plain dependency and a non-zero exit with the log tail', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' }) + const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) }) + + expect(await manager.install('ext-lib')).toMatchObject({ installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [] }) + expect((await manager.list()).find(view => view.name === 'ext-lib')?.status).toBe('plain') + await manager.uninstall('ext-lib') + expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false) + }) + + it('installs into a manifest that declares no dependencies yet', async () => { + const staged = await stageHome() + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [], patchReload: 'live' } } })) + stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW }) + const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => { + const path = join(staged.profileDir, 'package.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) as Record + writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { 'ext-new': '1.0.0' } })) + return { code: 0 } + }) }) + + expect(await manager.install('ext-new')).toMatchObject({ installed: ['ext-new'], installedOnly: ['ext-new'] }) + }) + + it('fails loud on a non-zero exit, a spawn error, a timeout, and an empty spec', async () => { + const staged = await stageHome() + const exits = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => ({ code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION\n' })) }) + await expect(exits.manager.install('nope')).rejects.toMatchObject({ + code: 'plugins/install-failed', details: { spec: 'nope', exitCode: 1, log: 'ERR_PNPM_NO_MATCHING_VERSION\n' }, + }) + expect(exits.log.at(-1)).toMatchObject({ exitCode: 1 }) + await expect(exits.manager.install(' ')).rejects.toMatchObject({ code: 'gateway/bad-request' }) + + const erroringHome = await stageHome() + const erroring = await bootProfile(erroringHome, { spawn: fakePnpm(erroringHome.profileDir, () => ({ code: null, error: 'spawn pnpm ENOENT' })) }) + await expect(erroring.manager.install('x')).rejects.toMatchObject({ code: 'plugins/install-failed', details: { exitCode: null } }) + expect(erroring.log.some(chunk => chunk.text.includes('ENOENT'))).toBe(true) + + const hangingHome = await stageHome() + const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) }) + await expect(hanging.manager.install('x')).rejects.toMatchObject({ code: 'plugins/install-failed' }) + expect(hanging.log.some(chunk => chunk.text.includes('timed out'))).toBe(true) + }) + + it('keeps only the tail of a long log in the failure', async () => { + const staged = await stageHome() + const { manager } = await bootProfile(staged, { + spawn: fakePnpm(staged.profileDir, () => ({ code: 2, stdout: 'a'.repeat(300), stderr: 'b'.repeat(300) })), + }, { installLogTailBytes: 256 }) + await expect(manager.install('x')).rejects.toMatchObject({ details: { log: 'b'.repeat(300) } }) + }) + }) + + describe('enable, disable, and retry', () => { + it('composes an installed bundle live, and takes it out again', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW }) + addDependency(staged.profileDir, 'ext-bundle') + const { ctx, manager, changes } = await bootProfile(staged) + + expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'live' }) + expect(entryIds(ctx)).toContain('include:ext-bundle/hello') + expect(await manager.enable('ext-bundle')).toEqual({ changed: false, effect: 'live' }) + expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'live' }) + expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello') + expect(await manager.disable('ext-bundle')).toEqual({ changed: false, effect: 'live' }) + expect(changes.map(change => [change.reason, change.packageName])).toEqual([ + ['enable', 'ext-bundle'], ['enable', 'ext-bundle'], ['disable', 'ext-bundle'], ['disable', 'ext-bundle'], + ]) + }) + + it('only writes the manifest on a startup-reload profile', async () => { + const staged = await stageHome('startup') + stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW }) + addDependency(staged.profileDir, 'ext-bundle') + const { ctx, manager } = await bootProfile(staged) + + expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'restart' }) + expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-bundle']) + expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello') + expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'restart' }) + }) + + it('refuses what cannot be enabled or disabled', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' }) + addDependency(staged.profileDir, 'ext-lib') + stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' }) + addDependency(staged.profileDir, 'ext-broken') + const { manager } = await bootProfile(staged) + + await expect(manager.enable('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' }) + await expect(manager.enable('ext-lib')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('declares no dsh.bundle') as string } }) + await expect(manager.enable('ext-broken')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('no import for you') as string } }) + // A template bundle is not a dependency and cannot be disabled. + const manifest = manifestOf(staged.profileDir) + manifest.dsh.profile.bundles.push('template') + writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2)) + await expect(manager.disable('template')).rejects.toMatchObject({ code: 'gateway/bad-request' }) + await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'gateway/bad-request' }) + }) + + it('restores the layer list when the tree rejects a boot-stage bundle', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n - id: bad\n name: cordis:throws\n', stage: 'boot' }) + addDependency(staged.profileDir, 'ext-fatal') + stagePackage(staged.profileDir, 'ext-fine', { patch: BUNDLE_ONE_ROW }) + addDependency(staged.profileDir, 'ext-fine') + const { ctx, manager } = await bootProfile(staged) + await manager.enable('ext-fine') + + await expect(manager.enable('ext-fatal')).rejects.toMatchObject({ + code: 'plugins/enable-failed', details: { packageName: 'ext-fatal', reason: expect.stringContaining('boom at apply') as string }, + }) + + expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine']) + expect(entryIds(ctx)).toContain('include:ext-fine/hello') + expect(entryIds(ctx)).not.toContain('include:bad') + }) + + it('retries an isolated bundle by composing it again', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-flaky', { patch: '- insert:\n - id: once\n name: cordis:flaky\n' }) + addDependency(staged.profileDir, 'ext-flaky') + flakyCalls = 0 + const { manager, changes } = await bootProfile(staged) + await manager.enable('ext-flaky') + expect((await manager.list())[0]).toMatchObject({ status: 'failed', reason: expect.stringContaining('flaky first start') as string }) + + expect(await manager.retry('ext-flaky')).toEqual({ changed: true, effect: 'live' }) + + expect((await manager.list())[0]).toMatchObject({ status: 'running' }) + expect(changes.map(change => change.reason)).toEqual(['enable', 'disable', 'enable', 'retry']) + }) + }) + + describe('rows in user layers', () => { + it('adds, disables, re-enables, and removes a row in the live global layer', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, '@acme/ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' }) + addDependency(staged.profileDir, '@acme/ext-plugin') + const { ctx, manager, changes } = await bootProfile(staged) + + const added = await manager.addRow('@acme/ext-plugin', { kind: 'global' }) + + expect(added).toEqual({ target: { kind: 'global' }, rowId: 'acme/ext-plugin', file: join(staged.profileDir, 'cordis.patch.yml') }) + expect(readFileSync(added.file, 'utf8')).toBe('- insert:\n - id: acme/ext-plugin\n name: "@acme/ext-plugin"\n config: {}\n') + // Composed live: the row is in the tree, though its module cannot import from the temp home. + expect(entryIds(ctx)).toContain('include:acme/ext-plugin') + await expect(manager.addRow('@acme/ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' }) + + await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', true) + expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(true) + expect(readFileSync(added.file, 'utf8')).toContain('- id: acme/ext-plugin\n disabled: true\n') + await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', false) + expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(false) + expect(readFileSync(added.file, 'utf8')).not.toContain('disabled') + + await manager.removeRow({ kind: 'global' }, 'acme/ext-plugin') + expect(entryIds(ctx)).not.toContain('include:acme/ext-plugin') + await expect(manager.removeRow({ kind: 'global' }, 'acme/ext-plugin')).rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(changes.map(change => change.reason)).toEqual(['row', 'row', 'row', 'row']) + }) + + it('detects a conflict through the layer file when the tree was not recomposed', async () => { + const staged = await stageHome('startup') + stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' }) + addDependency(staged.profileDir, 'ext-plugin') + const { ctx, manager } = await bootProfile(staged) + + await manager.addRow('ext-plugin', { kind: 'global' }) + + expect(entryIds(ctx)).not.toContain('include:ext-plugin') + await expect(manager.addRow('ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' }) + }) + + it('adds a declared addable module with its default config and an explicit id, and refuses the rest', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-bundle', { + patch: BUNDLE_ONE_ROW, + plugins: [ + { name: './tools/sql.js', title: 'SQL', config: { dsn: 'sqlite://' } }, + { name: './missing.js' }, + ], + files: { 'tools/sql.js': 'export const name = "sql"\nexport function apply() {}\n' }, + }) + addDependency(staged.profileDir, 'ext-bundle') + const { manager } = await bootProfile(staged) + + const added = await manager.addRow('ext-bundle', { kind: 'global' }, { module: './tools/sql.js', id: 'sql' }) + expect(added.rowId).toBe('sql') + expect(readFileSync(added.file, 'utf8')).toContain('- id: sql\n name: ext-bundle/tools/sql.js\n config:\n dsn: sqlite://\n') + await expect(manager.addRow('ext-bundle', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-enableable' }) + await expect(manager.addRow('ext-bundle', { kind: 'global' }, { module: './missing.js' })).rejects.toMatchObject({ code: 'plugins/not-enableable' }) + await expect(manager.addRow('absent', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-installed' }) + }) + + it('writes a preset\'s layer through the roster, and refuses a preset target without one', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' }) + addDependency(staged.profileDir, 'ext-plugin') + const { ctx, manager } = await bootProfile(staged) + await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable' }) + + const overlay = join(staged.home, '.agent-presets', 'standard', 'cordis.patch.yml') + const roster = { + overlayPathFor: (id: string) => Promise.resolve(join(staged.home, '.agent-presets', id, 'cordis.patch.yml')), + compositionInventory: () => Promise.resolve([{ id: 'standard', rows: [{ entryId: 'tool-web' }] }]), + list: () => Promise.resolve([{ id: 'standard', overlayPath: existsSync(overlay) ? overlay : undefined }]), + } + ctx.provide('agentPresets', roster) + // No layer exists yet: nothing references the package. + expect((await manager.dependents('ext-plugin')).references).toEqual([]) + // A preset the inventory does not know has no rows to conflict with. + const elsewhere = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'other' }) + expect(elsewhere.file).toBe(join(staged.home, '.agent-presets', 'other', 'cordis.patch.yml')) + + const added = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' }) + expect(added).toEqual({ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', file: overlay }) + await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' }, { id: 'tool-web' })).rejects.toMatchObject({ code: 'plugins/row-conflict' }) + await manager.setRowDisabled({ kind: 'preset', preset: 'standard' }, 'tool-web', true) + expect(readFileSync(overlay, 'utf8')).toBe('- insert:\n - id: ext-plugin\n name: ext-plugin\n config: {}\n- id: tool-web\n disabled: true\n') + // The preset's layer is not part of the host tree. + expect(entryIds(ctx)).not.toContain('include:ext-plugin') + + const dependents = await manager.dependents('ext-plugin') + expect(dependents.references).toEqual([{ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', moduleName: 'ext-plugin' }]) + await manager.removeRow({ kind: 'preset', preset: 'standard' }, 'ext-plugin') + expect(readFileSync(overlay, 'utf8')).toBe('- id: tool-web\n disabled: true\n') + }) + }) + + describe('dependents and uninstall', () => { + it('names the services other rows inject and the user-layer rows that reference the package', async () => { + const staged = await stageHome() + // Beside the provider: a row nobody injects, and a row switched off (no fiber to read). + stagePackage(staged.profileDir, 'ext-provider', { patch: '- insert:\n - id: svc\n name: cordis:provider\n - id: lonely\n name: cordis:lonely-provider\n - id: off\n name: cordis:good\n disabled: true\n' }) + addDependency(staged.profileDir, 'ext-provider') + writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), [ + '- insert:', + ' - name: ext-provider/anonymous.js', + ' disabled: true', + ' - id: ref', + ' name: ext-provider/tools/x.js', + ' disabled: true', + ' - id: grp', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: nested-ref', + ' name: ext-provider', + ' disabled: true', + '', + ].join('\n')) + const { manager, runtime } = await bootProfile(staged) + await manager.enable('ext-provider') + // A built-in row injecting the bundle's service, composed once the + // provider is up (a boot would refuse a row left waiting). + writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), `${readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')}- insert:\n - id: needs-svc\n name: cordis:consumer\n`) + await runtime.recompose() + + const dependents = await manager.dependents('ext-provider') + + expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:ext-provider/svc', injectedBy: ['include:needs-svc'] }]) + expect(dependents.references.map(reference => reference.rowId)).toEqual(['ref', 'nested-ref']) + expect(dependents.references[0]).toEqual({ target: { kind: 'global' }, rowId: 'ref', moduleName: 'ext-provider/tools/x.js' }) + }) + + it('ignores an unreadable user layer while collecting references', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' }) + addDependency(staged.profileDir, 'ext-lib') + const { manager } = await bootProfile(staged) + writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'a: [\n') + + expect((await manager.dependents('ext-lib')).references).toEqual([]) + }) + + it('disables, drops references, removes the package, and forgets its probe', async () => { + const staged = await stageHome() + stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js' }], files: { 'extra.js': 'export function apply() {}\n' } }) + addDependency(staged.profileDir, 'ext-bundle') + const calls: string[][] = [] + const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) }) + await manager.enable('ext-bundle') + await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' }) + expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:ext-bundle/hello', 'include:ext-bundle/extra.js'])) + expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true) + + await manager.uninstall('ext-bundle') + + expect(calls).toEqual([['pnpm', 'remove', 'ext-bundle']]) + expect(manifestOf(staged.profileDir)).toMatchObject({ dependencies: {}, dsh: { profile: { bundles: [] } } }) + expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello') + expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js') + expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') + expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false) + expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'disable', 'uninstall']) + await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' }) + }) + }) +}) + +describe('RemoteError codes', () => { + it('declare their details', () => { + const error = new RemoteError('plugins/row-conflict', 'taken', { rowId: 'x', target: { kind: 'global' } }) + expect(error.details).toEqual({ rowId: 'x', target: { kind: 'global' } }) + }) +}) diff --git a/packages/host/plugin-manager/tsconfig.json b/packages/host/plugin-manager/tsconfig.json new file mode 100644 index 0000000000..e956c9019b --- /dev/null +++ b/packages/host/plugin-manager/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../boot/app-boot" + }, + { + "path": "../../preset/agent-presets" + }, + { + "path": "../../util/patch-file" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/values" + } + ] +} diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 7e482db6c2..ae005afd72 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -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/preset/agent-presets/README.md -README.md: 5ddc40cc28a28429522ed324e114134cb59210ff -README.zh.md: bdb187e7f7aeb9c529bc073de85ec1bb2d3b463a +README.md: 3c14e366cbecf30128305d501b7534f15a92e96a +README.zh.md: 2fb755cf291a0b236caff9ed548bd778d7d52bb8 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5ddc40cc28..3c14e366cb 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -74,6 +74,22 @@ Authoring is copy-only: creating a preset copies an existing preset's whole dire A copy is refused when the id is not `[a-z0-9][a-z0-9-]*` (the id becomes a directory name), when the id is already taken (a copy never overwrites), or when the source is unknown. Deleting removes only locally authored presets; presets that ship with the deployment are not removable. A session already running on a deleted preset keeps running on it. +### Adjusting a preset without editing it + +Every preset accepts a user patch layer: a `cordis.patch.yml` in the Loader's patch-list format — the same file a profile's user layer is — that switches rows off, replaces a row's config, or inserts rows, applied over the composition at every mount. For a locally authored preset the file sits beside `agent.cordis.yml`; for a shipped preset it sits alone in `/.agent-presets//cordis.patch.yml`, so the shipped install stays as it came: + +```yaml +- id: tool-web + disabled: true +- insert: + - id: hide-fetch + name: '@deepseek-ai/dsh-global-tool-mask' + config: + deny: [web_fetch] +``` + +The layer is judged with the composition: a layer that does not parse, inserts a malformed row, or inserts a row naming a module that cannot resolve makes the preset broken with that reason, and a layer whose id no root supplies is listed as a broken slot. Sessions created after the layer changes compose the new content; running sessions keep theirs. The composition inventory reports each row's `source` (`preset` or `user`) and, for a row that is off, whether the composition or the layer switched it off. A copy carries the layer in beside the new composition, and `removeOverlay` deletes it. The [plugin manager](../../host/plugin-manager/README.md) writes this file through `overlayPathFor`. + ### Switching a session's preset A session can switch to a different preset only while it has produced nothing — no messages or tool calls. After that, the composition is fixed for the session's life, because swapping tools mid-conversation would leave logged tool calls the new composition cannot make. A committed switch emits `tools/change` because the resolved tool set changed without a registry edit. The switch is also recorded in the session log, so a resumed or forked session rebuilds under the composition it ran. @@ -95,7 +111,7 @@ This section explains the design behind the roster and the standing mount; obser ### Design philosophy - **One standing composition per preset.** A preset is mounted once per process under a standing scope; agents join by parenting their scope key to the mount, so the mount's registrations and listeners cover every joined agent and no sibling preset's. -- **Generations keyed on the composition file.** The mount records the composition file's stamp (mtime and size); a session that finds the stamp stale starts the next generation, while sessions already joined keep the generation they run on — a running session outlives its file changing or disappearing. +- **Generations keyed on the composition file and the user patch layer.** The mount records the composition file's stamp (mtime and size) and a digest of the layer's text; a session that finds the stamp stale starts the next generation, while sessions already joined keep the generation they run on — a running session outlives its file changing or disappearing. A layer edited back to a content an earlier generation composed returns to that generation instead of composing a third. - **The preset file is an input, never a persistence target.** The mounted subtree overrides `write()` as a no-op, so a loader-initiated write-back never rewrites a shared preset file. - **Discovery owns health.** A directory whose composition is missing or unloadable is a broken roster row with a reason, not a skip — a skipped directory would still occupy its id while no surface shows anything to delete. @@ -104,11 +120,11 @@ This section explains the design behind the roster and the standing mount; obser | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Service entry: `Config` schema, settings namespace, roster API, standing-mount coordination | -| [`src/discovery.ts`](src/discovery.ts) | Filesystem discovery: root scanning, health checks, id validation, ordering | -| [`src/composition-inventory.ts`](src/composition-inventory.ts) | Flattened composition rows for plugin-listing surfaces: file reads with evaluated disabled gates, mount reads with fiber states | +| [`src/discovery.ts`](src/discovery.ts) | Filesystem discovery: root scanning, user patch layer attachment, health checks, id validation, ordering | +| [`src/composition-inventory.ts`](src/composition-inventory.ts) | Flattened composition rows for plugin-listing surfaces: file reads with evaluated disabled gates and the user patch layer applied, mount reads with fiber states, per-row `source` and `disabledBy` | | [`src/preset.ts`](src/preset.ts) | Vocabulary: preset id rule, `AgentPreset` and `PresetRoot`, error types | -| [`src/mount.ts`](src/mount.ts) | Subtree mounting, host base-URL handling, mount audit, `write()` suppression | -| [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, permission tightening | +| [`src/mount.ts`](src/mount.ts) | Subtree mounting with the user patch layer as runtime patches, host base-URL handling, mount audit, `write()` suppression | +| [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, user patch layer copy and removal, permission tightening | | [`src/metadata.ts`](src/metadata.ts) | `preset.yml` display metadata | | [`src/session.ts`](src/session.ts) | `agent-preset/selected` event and the `agentPreset` Session projection | | [`src/types.ts`](src/types.ts) | Client-safe wire payloads and cordis event declaration | @@ -171,7 +187,7 @@ These limits define when the roster is a poor fit or needs special operational c - **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the first `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and answers "it does not live under the writable preset root" for every delete. A deployment that wants only its own presets sets `includeUserRoot: false`. - **A session cannot change preset once it has produced anything** — switching re-links a blank session's parent scope to another standing mount, and only a blank one: swapping tools mid-conversation would strand tools the model has called. -- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. +- **A generation is keyed on the composition file and the user patch layer alone** — the stamp check notices `agent.cordis.yml` or `cordis.patch.yml` changing, not an edit to a skill file or asset beside them; those reach new sessions only once one of the two files itself moves or the process restarts. - **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-filesystem` watches its roots by default, so each edit-then-create cycle adds a live watcher set. - **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start. - **Health asks what is installed, not what would import** — discovery proves the composition parses in the loader dialect, holds named rows, and that each row it can prove will start names a package present above the harness base or a file that exists; it never imports one, so a package whose own entry file is missing, a plugin that throws on apply, and one waiting forever for a service all still fail at the first session. `disabled` is the one entry field the Loader interpolates, so a row carrying an expression there is left unchecked rather than judged from the file. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index bdb187e7f7..2fb755cf29 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -74,6 +74,22 @@ agent-presets: 以下情况会拒绝复制:id 不符合 `[a-z0-9][a-z0-9-]*`(id 会成为目录名)、id 已被占用(复制从不覆写)、或来源未知。删除只移除本地创作的 preset;随部署提供的 preset 不可删除。已在被删除 preset 上运行的会话会继续运行。 +### 不编辑 preset 也能调整它 + +每个 preset 都接受一层用户补丁:一个采用 Loader 补丁列表格式的 `cordis.patch.yml`——与 profile 的用户层是同一种文件——用来关掉行、替换某行的 config 或插入行,在每次挂载时施加在组装之上。本地创作的 preset 把它放在 `agent.cordis.yml` 旁边;随附 preset 则单独放在 `/.agent-presets//cordis.patch.yml`,随附安装保持原样: + +```yaml +- id: tool-web + disabled: true +- insert: + - id: hide-fetch + name: '@deepseek-ai/dsh-global-tool-mask' + config: + deny: [web_fetch] +``` + +这一层与组装一起判定:无法解析的层、插入了畸形行、或插入了引用无法解析模块的行,都会让 preset 带着该原因变为 broken;没有任何根目录提供其 id 的层会被列为损坏的槽位。在层变化之后创建的会话组合新内容;运行中的会话保持各自的。组合清单报告每一行的 `source`(`preset` 或 `user`),以及被关掉的行是组装还是这一层关掉的。复制会把这一层带到新组装旁边,`removeOverlay` 删除它。[插件管理器](../../host/plugin-manager/README.zh.md) 经 `overlayPathFor` 写入这个文件。 + ### 切换会话的 preset 会话只有在尚未产出任何内容——没有消息或工具调用——时才能切换到不同的 preset。此后组装在会话的生命周期内固定,因为在对话中途调换工具会留下新组装无法执行的已记录工具调用。已提交的切换会发出 `tools/change`,因为解析后的工具集在没有注册表编辑的情况下发生了变化。切换也会记入会话日志,因此恢复或 fork 的会话会按它运行的组装重建。 @@ -95,7 +111,7 @@ agent-presets: ### 设计理念 - **每个 preset 一份常驻组装。** preset 在进程内只挂载一次,挂到常驻 scope 之下;agent 通过把自己的 scope key 认父到该挂载来加入,因此挂载的注册与监听器覆盖每个已加入的 agent,而不覆盖兄弟 preset 的。 -- **代际以组装文件为键。** 挂载记录组装文件的 stamp(mtime 与大小);发现 stamp 过期的会话会开启下一个代际,而已加入的会话保持各自运行的那个代际——运行中的会话在文件被修改或删除后继续存活。 +- **代际以组装文件与用户补丁层为键。** 挂载记录组装文件的 stamp(mtime 与大小)以及补丁层文本的摘要;发现 stamp 过期的会话会开启下一个代际,而已加入的会话保持各自运行的那个代际——运行中的会话在文件被修改或删除后继续存活。补丁层被改回某个早先代际组装过的内容时,回到那一代而不是再组装第三个。 - **preset 文件是输入,绝不是持久化目标。** 被挂载的子树把 `write()` 覆写为空操作,因此 loader 发起的写回绝不会重写共享的 preset 文件。 - **发现过程拥有健康。** 组装缺失或不可加载的目录是携带原因的 broken 名单行,而不是被跳过——被跳过的目录仍占着它的 id,而任何界面都没有可删的东西。 @@ -104,11 +120,11 @@ agent-presets: | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 服务入口:`Config` schema、settings 命名空间、名单 API、常驻挂载协调 | -| [`src/discovery.ts`](src/discovery.ts) | 文件系统发现:根目录扫描、健康检查、id 校验、排序 | -| [`src/composition-inventory.ts`](src/composition-inventory.ts) | 面向插件清单表面的压平组合行:文件读取(求值 disabled 门)与挂载读取(携带 fiber 状态) | +| [`src/discovery.ts`](src/discovery.ts) | 文件系统发现:根目录扫描、用户补丁层附着、健康检查、id 校验、排序 | +| [`src/composition-inventory.ts`](src/composition-inventory.ts) | 面向插件清单表面的压平组合行:文件读取(求值 disabled 门并施加用户补丁层)与挂载读取(携带 fiber 状态),每行的 `source` 与 `disabledBy` | | [`src/preset.ts`](src/preset.ts) | 词汇体系:preset id 规则、`AgentPreset` 与 `PresetRoot`、错误类型 | -| [`src/mount.ts`](src/mount.ts) | 子树挂载、宿主 base-URL 处理、挂载审计、`write()` 抑制 | -| [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、权限收紧 | +| [`src/mount.ts`](src/mount.ts) | 以用户补丁层为运行时 patches 的子树挂载、宿主 base-URL 处理、挂载审计、`write()` 抑制 | +| [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、用户补丁层的复制与移除、权限收紧 | | [`src/metadata.ts`](src/metadata.ts) | `preset.yml` 展示元数据 | | [`src/session.ts`](src/session.ts) | `agent-preset/selected` 事件与 `agentPreset` Session 投影 | | [`src/types.ts`](src/types.ts) | client-safe 的线上载荷与 cordis 事件声明 | @@ -171,7 +187,7 @@ agent-presets: - **位于可写根目录之外的 preset 可被发现却无法删除**——`remove()` 拒绝任何不在第一个 `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。只想要自有 preset 的部署应设置 `includeUserRoot: false`。 - **会话一旦产出任何内容便无法更换 preset**——切换会把空白会话的父作用域重链到另一个常驻挂载,且仅限空白会话:在对话中途调换工具会抽走模型已调用的工具。 -- **代际只以组装文件为键**——stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 +- **代际只以组装文件与用户补丁层为键**——stamp 检查只察觉 `agent.cordis.yml` 或 `cordis.patch.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等这两个文件之一本身变动或进程重启才达到新会话。 - **被替代的代际永不回收**——已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-filesystem` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。 - **副本从不被实际挂载以校验**——它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。 - **健康问的是「装没装」,不是「能不能 import」**——发现过程证明组装能以加载器方言解析、由具名行组成,且每一行它能证明会启动的行所引用的包装在 harness 基准之上、或所引用的文件确实存在;它从不 import 任何一个,因此入口文件缺失的包、在 apply 时抛错的插件、以及永远等待某个服务的插件,都仍在第一个会话处失败。`disabled` 是加载器唯一会插值的条目字段,因此在该字段写了表达式的行会被跳过,而不是仅凭文件下判断。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a8541e77af..884ecd16bd 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-patch-file": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-patch-file": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 5053f2aff3..6000246634 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -17,6 +17,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { expandHomePath } from '@deepseek-ai/dsh-home-paths' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { OVERLAY_FILE } from './discovery.ts' import { METADATA_FILE, renderPresetMetadata } from './metadata.ts' import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' @@ -116,6 +117,11 @@ async function tightenModes(dir: string): Promise { * sorted into the shipped set's declared order, would make the roster stop * distinguishing them. With no name given and no description to keep, the * file is removed so the copy publishes nothing rather than a blank. + * + * The source's user patch layer travels with the copy: a layer beside the + * composition is copied with the directory, and a shipped preset's layer — + * kept in the user root's slot of the same id — is copied in beside the new + * composition, so the copy composes exactly what its source composed. * @param roots - the configured roots; the first `user` one receives the copy. * @param source - the resolved preset the copy starts from. * @param id - the new preset's id, which becomes its directory name. @@ -144,6 +150,9 @@ export async function copyComposition( await cp(dirname(source.path), dir, { recursive: true, dereference: true, force: false, errorOnExist: true, }) + if (source.overlayPath !== undefined && dirname(source.overlayPath) !== dirname(source.path)) { + await cp(source.overlayPath, join(dir, OVERLAY_FILE), { dereference: true, force: false, errorOnExist: true }) + } await tightenModes(dir) const rendered = renderPresetMetadata({ ...name === undefined ? {} : { name }, @@ -189,3 +198,29 @@ export async function deleteComposition( } await rm(dir, { recursive: true, force: true }) } + +/** + * Delete a preset's user patch layer, restoring the composition as its root + * supplies it. Only a layer under the writable root is deletable: a layer + * beside a shipped composition would be part of the deployment. + * @param roots - the configured roots. + * @param preset - the resolved preset whose layer to remove. + * @returns true when a layer was removed; false when the preset had none. + * @throws when the layer lies outside the writable root. + */ +export async function deleteOverlay( + roots: readonly PresetRoot[], + preset: AgentPreset, +): Promise { + if (preset.overlayPath === undefined) return false + const dir = join(writableRoot(roots, preset.id), preset.id) + if (!isAbsolute(preset.overlayPath) || !preset.overlayPath.startsWith(dir)) { + throw notWritable(preset.id, 'its user patch layer does not live under the writable preset root') + } + await rm(preset.overlayPath, { force: true }) + // A shipped preset's slot exists only to hold the layer; an emptied slot + // would otherwise read as a directory missing its composition. + const remaining = await readdir(dir) + if (remaining.length === 0) await rm(dir, { recursive: true, force: true }) + return true +} diff --git a/packages/preset/agent-presets/src/composition-inventory.ts b/packages/preset/agent-presets/src/composition-inventory.ts index 101cc34ed1..f9bd092325 100644 --- a/packages/preset/agent-presets/src/composition-inventory.ts +++ b/packages/preset/agent-presets/src/composition-inventory.ts @@ -6,15 +6,18 @@ * boot answers from its composition file, with `!!js` disabled expressions * evaluated through the caller-supplied Loader evaluator so the file answer * matches the decision a mount on this host would make. A row whose - * expression the evaluator refuses stays `'conditional'`. + * expression the evaluator refuses stays `'conditional'`. Either way the + * preset's user patch layer is accounted for: a row the layer inserted reads + * `source: 'user'`, and a row it switched off with a literal `disabled: true` + * reads `disabledBy: 'user'`. * @module @deepseek-ai/dsh-agent-presets/composition-inventory */ import { readFile } from 'node:fs/promises' import { load } from 'js-yaml' import type { FiberState } from '@deepseek-ai/cordis' -import { isJsExpr, type EntryTree } from '@deepseek-ai/cordis-plugin-loader' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { isJsExpr, type EntryOptions, type EntryTree } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { entryListProblem } from './discovery.ts' import type { PresetTrust } from './preset.ts' @@ -32,6 +35,12 @@ export type CompositionRowEnablement = boolean | 'conditional' */ export type DisabledExpressionEvaluator = (expression: string) => unknown +/** Who put a row into a preset's composition: its file, or the user patch layer over it. */ +export type CompositionRowSource = 'preset' | 'user' + +/** Why a row is off: the composition's own gate, or the user patch layer's literal `disabled: true`. */ +export type CompositionRowDisabledBy = 'composition' | 'user' + /** One plugin row a preset composition names. */ export interface AgentPresetCompositionRow { /** @@ -47,8 +56,46 @@ export interface AgentPresetCompositionRow { readonly condition?: string /** Root-fiber state, present only when read from a live mount. */ readonly fiberState?: FiberState + /** Whether the composition file or the user patch layer supplied the row. */ + readonly source: CompositionRowSource + /** Present exactly when `enabled` is false. */ + readonly disabledBy?: CompositionRowDisabledBy } +/** What a user patch layer decided about a composition's rows, by row id. */ +export interface OverlayFacts { + /** Ids of rows the layer inserted, including rows inside inserted groups. */ + readonly inserted: ReadonlySet + /** Ids of rows the layer switched off with a literal `disabled: true`. */ + readonly disabled: ReadonlySet +} + +/** + * Read one user patch layer's decisions by row id. A `!!js` gate in the layer + * is a condition, not a user decision, and is left to the composition. + * @param patches - the layer's patch list, parsed; empty when the preset has none. + * @returns the inserted and user-disabled row ids. + */ +export function overlayFacts(patches: readonly PatchOptions[]): OverlayFacts { + const inserted = new Set() + const disabled = new Set() + const visit = (row: EntryOptions): void => { + if (typeof row.id === 'string') inserted.add(row.id) + if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit) + } + for (const patch of patches) { + if (patch.insert !== undefined) { + patch.insert.forEach(visit) + continue + } + if (typeof patch.id === 'string' && patch.disabled === true) disabled.add(patch.id) + } + return { inserted, disabled } +} + +/** The empty layer: nothing inserted, nothing switched off. */ +const NO_OVERLAY: OverlayFacts = { inserted: new Set(), disabled: new Set() } + /** One preset's roster identity beside its composition rows. */ export interface AgentPresetComposition { /** Stable preset id. */ @@ -129,39 +176,56 @@ function flattenRows( rows: readonly unknown[], outerDisabled: boolean | 'conditional', evaluateExpression: DisabledExpressionEvaluator, + overlay: OverlayFacts, found: AgentPresetCompositionRow[], ): void { for (const value of rows) { const row = value as RawRow const disabled = combineDisabled(outerDisabled, disabledContribution(row.disabled, evaluateExpression)) if (row.group === true) { - flattenRows(row.config as readonly unknown[], disabled, evaluateExpression, found) + flattenRows(row.config as readonly unknown[], disabled, evaluateExpression, overlay, found) continue } + const id = typeof row.id === 'string' && row.id !== '' ? row.id : null found.push({ - entryId: typeof row.id === 'string' && row.id !== '' ? row.id : null, + entryId: id, moduleName: row.name, enabled: disabled === true ? false : disabled === 'conditional' ? 'conditional' : true, ...isJsExpr(row.disabled) ? { condition: row.disabled.__jsExpr } : {}, + ...rowProvenance(id, disabled === true, overlay), }) } } +/** The `source` and `disabledBy` fields of one row, from the layer's decisions. */ +function rowProvenance( + id: string | null, disabled: boolean, overlay: OverlayFacts, +): Pick { + const userRow = id !== null && overlay.inserted.has(id) + return { + source: userRow ? 'user' : 'preset', + ...disabled ? { disabledBy: id !== null && overlay.disabled.has(id) ? 'user' as const : 'composition' as const } : {}, + } +} + /** * Plugin rows of one composition file, for a preset with no live mount. * * Parsed with the Loader's own dialect ({@link entryListSchema}), so the rows - * reported are the rows a mount would start from. A file that stopped reading - * as a composition — discovery judged the preset healthy moments earlier, so - * only an edit racing this read gets here — answers as broken with the raced - * reason rather than dropping the rows silently. + * reported are the rows a mount would start from, with the user patch layer + * applied through the include's own patch algorithm. A file that stopped + * reading as a composition — discovery judged the preset healthy moments + * earlier, so only an edit racing this read gets here — answers as broken + * with the raced reason rather than dropping the rows silently. * @param path - absolute path of the composition file. * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. + * @param overlay - the preset's user patch layer, parsed; empty when it has none. * @returns flattened rows in composition order, or why they cannot be read. */ export async function fileComposition( path: string, evaluateExpression: DisabledExpressionEvaluator, + overlay: readonly PatchOptions[] = [], ): Promise<{ rows: AgentPresetCompositionRow[] } | { broken: string }> { let rows: unknown try { @@ -172,17 +236,22 @@ export async function fileComposition( } const problem = entryListProblem(rows) if (problem !== undefined) return { broken: problem } + // Silent: a patch that matches no row is the Loader's warning at mount + // time, not an inventory failure. + const composed = applyEntryPatches(rows as EntryOptions[], [...overlay], () => {}) const found: AgentPresetCompositionRow[] = [] - flattenRows(rows as readonly unknown[], false, evaluateExpression, found) + flattenRows(composed, false, evaluateExpression, overlay.length === 0 ? NO_OVERLAY : overlayFacts(overlay), found) return { rows: found } } /** * Plugin rows of one live standing composition, in Loader-entry order. * @param tree - the standing mount's entry tree. + * @param overlay - the preset's user patch layer, parsed; empty when it has none. * @returns rows with the Loader's evaluated enablement and root-fiber states. */ -export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionRow[] { +export function mountedCompositionRows(tree: EntryTree, overlay: readonly PatchOptions[] = []): AgentPresetCompositionRow[] { + const facts = overlay.length === 0 ? NO_OVERLAY : overlayFacts(overlay) const found: AgentPresetCompositionRow[] = [] for (const entry of tree.entries()) { if (entry.options.group) continue @@ -192,6 +261,9 @@ export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionR enabled: !entry.disabled, ...isJsExpr(entry.options.disabled) ? { condition: entry.options.disabled.__jsExpr } : {}, ...entry.fiber === undefined ? {} : { fiberState: entry.fiber.state }, + // Provenance and user patches address rows by the id the composition + // declares; the tree-wide `entry.id` carries the include's prefix. + ...rowProvenance(entry.options.id, entry.disabled, facts), }) } return found diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 8d39410b00..815a87150f 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -5,6 +5,12 @@ * re-reads the roots on every call so a preset authored while the process is * running is visible without a restart. * + * A preset may carry a user patch layer, {@link OVERLAY_FILE}: beside its + * composition, or — for a shipped preset — alone in the user root's directory + * of the same id. Discovery attaches the layer to the preset that wins the + * id, so the mount applies it and the inventory reports it, and judges its + * health with the composition's. + * * Discovery also owns preset HEALTH: a directory whose composition is * missing or unloadable is reported as a broken roster row rather than * skipped. A skipped directory would still occupy its id on disk — the copy @@ -27,15 +33,25 @@ import { isBuiltin } from 'node:module' import { dirname, join, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { load } from 'js-yaml' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { expandHomePath } from '@deepseek-ai/dsh-home-paths' +import { readPatchListFile } from '@deepseek-ai/dsh-patch-file' import { readPresetMetadata } from './metadata.ts' -import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' +import { PRESET_ID, type AgentPreset, type PresetRoot, type PresetTrust } from './preset.ts' import { classifyRowSpecifier, type RowSpecifier } from './specifier.ts' /** The composition file that makes a directory a preset. */ export const COMPOSITION_FILE = 'agent.cordis.yml' +/** + * The user patch layer over a preset's composition: a Loader patch list in + * the same format as a profile's `cordis.patch.yml`, applied at every mount. + */ +export const OVERLAY_FILE = 'cordis.patch.yml' + +/** Diagnostic prefix on overlay parse errors. */ +const BIN_NAME = 'agent-presets' + /** * Harness-home directory holding locally authored presets. * @@ -256,6 +272,50 @@ async function compositionProblem(path: string, harnessBase: string): Promise `- ${row.label}: ${row.name}`).join('\n') } +/** + * Why the user patch layer at `path` cannot apply, or undefined when it can. + * Parsed with the shared patch-list parser, so a layer the profile launcher + * would accept is never called broken here; every inserted row is then + * shape-checked and resolved the way composition rows are. + * @param path - absolute path of the overlay file. + * @param harnessBase - base URL a row's package name resolves against. + * @returns one human-readable reason, or undefined when the layer is loadable. + */ +async function overlayProblem(path: string, harnessBase: string): Promise { + let patches: PatchOptions[] | undefined + try { + patches = await readPatchListFile(BIN_NAME, path, 'user patch layer') + } catch (error) { + /* v8 ignore next -- the parser throws Errors for every failure; the fallback keeps a hostile value readable */ + const full = error instanceof Error ? error.message : String(error) + return `the user patch layer ${OVERLAY_FILE} cannot be applied: ${full.replace(/\n[\s\S]*$/, '')}` + } + // The caller statted this file moments ago; gone in between reads as nothing to apply. + if (patches === undefined) return undefined + const presetBase = new URL('.', pathToFileURL(path)).href + for (const [index, patch] of patches.entries()) { + if (patch.insert === undefined) continue + const shape = entryListProblem(patch.insert, `user patch layer entry ${String(index + 1)}`) + if (shape !== undefined) return shape + const unresolvable = await unresolvableRows(patch.insert, presetBase, harnessBase, `user patch layer entry ${String(index + 1)}`) + const [first] = unresolvable + if (first !== undefined) { + return `the user patch layer ${OVERLAY_FILE} inserts ${first.label}, which names a plugin that cannot be resolved: ${first.name}` + } + } + return undefined +} + +/** One user patch layer found alone in a preset slot: a directory with the layer and no composition. */ +interface OverlayRecord { + /** The preset id the layer applies to; the directory's name. */ + readonly id: string + /** Absolute path of the layer file. */ + readonly path: string + /** Trust of the root the layer was found under. */ + readonly trust: PresetTrust +} + /** * Whether `path` names an existing regular file. * @param path - absolute path to test. @@ -290,30 +350,11 @@ async function isFile(path: string): Promise { * @returns the root's presets ordered by id. */ export async function scanRoot(root: PresetRoot, harnessBase: string): Promise { - const dir = resolve(expandHomePath(root.path)) - let children - try { - children = await readdir(dir, { withFileTypes: true }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error }) - } - const found: AgentPreset[] = [] - for (const child of children) { - if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue - const directory = join(dir, child.name) - const path = join(directory, COMPOSITION_FILE) - const broken = await isFile(path) - ? await compositionProblem(path, harnessBase) - : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file` - // Display text only, and never fatal: a preset with unreadable metadata - // still mounts, it just shows its id. - const metadata = await readPresetMetadata(directory) - found.push({ - id: child.name, trust: root.trust, path, ...metadata, - ...broken === undefined ? {} : { broken }, - }) - } + return (await scanRootSlots(root, harnessBase)).presets +} + +/** Sort presets by declared order, then id. */ +function sortPresets(found: AgentPreset[]): AgentPreset[] { // Declared order first so the shipped set reads by capability; everything // else falls back to the id, which keeps authored presets stable. return found.sort((left, right) => { @@ -322,8 +363,62 @@ export async function scanRoot(root: PresetRoot, harnessBase: string): Promise { + const dir = resolve(expandHomePath(root.path)) + let children + try { + children = await readdir(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { presets: [], overlays: [] } + throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error }) + } + const presets: AgentPreset[] = [] + const overlays: OverlayRecord[] = [] + for (const child of children) { + if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue + const directory = join(dir, child.name) + const path = join(directory, COMPOSITION_FILE) + const overlayPath = join(directory, OVERLAY_FILE) + const hasOverlay = await isFile(overlayPath) + if (!await isFile(path)) { + // A layer alone in the slot is not a preset: it belongs to the preset + // of the same id another root supplies, and only fails when none does. + if (hasOverlay) { + overlays.push({ id: child.name, path: overlayPath, trust: root.trust }) + continue + } + } + const broken = await isFile(path) + ? await compositionProblem(path, harnessBase) ?? (hasOverlay ? await overlayProblem(overlayPath, harnessBase) : undefined) + : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file` + // Display text only, and never fatal: a preset with unreadable metadata + // still mounts, it just shows its id. + const metadata = await readPresetMetadata(directory) + presets.push({ + id: child.name, trust: root.trust, path, ...metadata, + ...hasOverlay ? { overlayPath } : {}, + ...broken === undefined ? {} : { broken }, + }) + } + return { presets: sortPresets(presets), overlays } +} + /** * Scan every root in precedence order. + * + * A user patch layer found alone in a later root's slot attaches to the + * preset that won the id, unless that preset carries a layer of its own; one + * whose id no root supplies becomes a broken row, because the directory still + * occupies the id and a roster that hid it would leave nothing to delete. * @param roots - roots in precedence order; an earlier root wins a duplicate id. * @param harnessBase - base URL a row's package name resolves against. * @returns every discovered preset, first-root-wins per id. @@ -333,11 +428,30 @@ export async function discoverPresets( harnessBase: string, ): Promise { const byId = new Map() + const loneOverlays: OverlayRecord[] = [] for (const root of roots) { - for (const preset of await scanRoot(root, harnessBase)) { + const { presets, overlays } = await scanRootSlots(root, harnessBase) + for (const preset of presets) { if (byId.has(preset.id)) continue byId.set(preset.id, preset) } + loneOverlays.push(...overlays) + } + for (const overlay of loneOverlays) { + const preset = byId.get(overlay.id) + if (preset === undefined) { + byId.set(overlay.id, { + id: overlay.id, + trust: overlay.trust, + path: join(dirname(overlay.path), COMPOSITION_FILE), + overlayPath: overlay.path, + broken: `the directory holds a user patch layer ${OVERLAY_FILE} but no root supplies a preset "${overlay.id}" for it to apply to — delete the directory or restore the preset`, + }) + continue + } + if (preset.overlayPath !== undefined) continue + const problem = preset.broken === undefined ? await overlayProblem(overlay.path, harnessBase) : undefined + byId.set(preset.id, { ...preset, overlayPath: overlay.path, ...problem === undefined ? {} : { broken: problem } }) } return [...byId.values()] } diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index e3576868ef..06911cb82e 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -21,8 +21,11 @@ * @module @deepseek-ai/dsh-agent-presets */ -import { stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -37,8 +40,9 @@ import type {} from '@deepseek-ai/dsh-tools' import type SettingsService from '@deepseek-ai/dsh-settings' import type { SettingsScope } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' -import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' -import { copyComposition, deleteComposition, presetExists, readComposition } from './authoring.ts' +import { readPatchListFile } from '@deepseek-ai/dsh-patch-file' +import { discoverPresets, OVERLAY_FILE, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' +import { copyComposition, deleteComposition, deleteOverlay, presetExists, readComposition, writableRoot } from './authoring.ts' import { livePresetMounts, mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { fileComposition, mountedCompositionRows, @@ -48,8 +52,10 @@ import type { AgentPreset, Config, PresetRoot } from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' export type * from './types.ts' export type { - AgentPresetComposition, AgentPresetCompositionRow, CompositionRowEnablement, + AgentPresetComposition, AgentPresetCompositionRow, CompositionRowDisabledBy, CompositionRowEnablement, + CompositionRowSource, OverlayFacts, } from './composition-inventory.ts' +export { overlayFacts } from './composition-inventory.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' @@ -72,7 +78,7 @@ export const AgentPresetSettingsSchema: z = z.object({ default: z.string(), }) -export { COMPOSITION_FILE, discoverPresets, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts' +export { COMPOSITION_FILE, discoverPresets, OVERLAY_FILE, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts' export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' @@ -80,7 +86,7 @@ export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, type JoinedPresetMount, type PresetMount, } from './mount.ts' -export { copyComposition, deleteComposition, readComposition, writableRoot } from './authoring.ts' +export { copyComposition, deleteComposition, deleteOverlay, readComposition, writableRoot } from './authoring.ts' export { agentPresetProjectionDefinition } from './session.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts' @@ -315,14 +321,14 @@ export class AgentPresets extends TypertRemoteService { // superseded generation's record precedes its replacement's. const mount = livePresetMounts(rootFiber).findLast(candidate => candidate.presetId === preset.id) if (mount !== undefined) { - found.push({ ...identity, rows: mountedCompositionRows(mount.tree) }) + found.push({ ...identity, rows: mountedCompositionRows(mount.tree, await readOverlay(preset)) }) continue } if (preset.broken !== undefined) { found.push({ ...identity, broken: preset.broken, rows: [] }) continue } - const read = await fileComposition(preset.path, evaluateExpression) + const read = await fileComposition(preset.path, evaluateExpression, await readOverlay(preset)) found.push('broken' in read ? { ...identity, broken: read.broken, rows: [] } : { ...identity, rows: read.rows }) @@ -381,15 +387,24 @@ export class AgentPresets extends TypertRemoteService { * Standing mounts by preset id, single-flight so two agents racing the * first use of one preset share one composition. A settled failure is * removed so a later session retries a preset whose file has been fixed; a - * settled success serves until the composition FILE visibly changes — each - * generation records its file stamp, and a stale stamp starts the next - * generation for sessions created afterwards. Sessions already joined keep - * the generation they run on; a superseded one is never disposed while the - * process lives (reclaimed only by whole-tree teardown), so editing files - * is bounded by how often compositions change, not by session count. + * settled success serves until the composition FILE or the user patch + * layer visibly changes — each generation records its stamp, and a stale + * stamp starts the next generation for sessions created afterwards. + * Sessions already joined keep the generation they run on; a superseded + * one is never disposed while the process lives (reclaimed only by + * whole-tree teardown), so editing files is bounded by how often + * compositions change, not by session count. */ private readonly standing = new Map>() + /** + * Superseded generations by preset id and stamp key. A layer edited back + * to an earlier content — a row switched off and on again — returns to the + * generation that content already composed instead of composing a third, + * which keeps a toggle from stacking live subtrees. + */ + private readonly retired = new Map>() + /** * Parent bindings of the agents this roster composed, keyed by the agent's * scope key. The binding is dsh-scope's only re-link capability; holding it @@ -502,6 +517,33 @@ export class AgentPresets extends TypertRemoteService { return await readComposition(await this.resolve(id)) } + /** + * Where one preset's user patch layer is, or would be written: the layer + * discovery attached, else the writable root's slot of the same id — beside + * the composition for a locally authored preset, alone in the slot for a + * shipped one. The file need not exist yet. + * @param id - the preset id. + * @returns the absolute path of the layer file. + * @throws when the preset is unknown, or it has no layer and the + * deployment configures no writable root. + */ + async overlayPathFor(id: string): Promise { + const preset = await this.resolve(id) + return preset.overlayPath ?? join(writableRoot(this.resolvedRoots, id), id, OVERLAY_FILE) + } + + /** + * Delete one preset's user patch layer, so the next generation composes the + * preset exactly as its root supplies it. Sessions already joined keep the + * generation they run on. + * @param id - the preset id. + * @returns true when a layer was removed; false when the preset had none. + * @throws when the preset is unknown or its layer lies outside the writable root. + */ + async removeOverlay(id: string): Promise { + return await deleteOverlay(this.resolvedRoots, await this.resolve(id)) + } + /** * One preset's composition text with the roster row it belongs to. * @param agentPreset - the preset id. @@ -748,12 +790,13 @@ export class AgentPresets extends TypertRemoteService { const pending = this.standing.get(preset.id) if (pending !== undefined) { const mounted = await pending - // Files are the only composition editor (authoring is copy/delete), so - // the stamp is what notices an edit: a changed file starts the next - // generation here, for this and later sessions. An unreadable stamp - // serves the current generation — a mount must survive its file - // disappearing, and failing the session over a stat would not. - const current = await compositionStamp(preset.path) + // Files are the only composition editor (authoring is copy/delete, the + // user patch layer is a file), so the stamp is what notices an edit: a + // changed file starts the next generation here, for this and later + // sessions. An unreadable stamp serves the current generation — a + // mount must survive its file disappearing, and failing the session + // over a stat would not. + const current = await compositionStamp(preset) if (current === undefined || sameStamp(mounted.stamp, current)) return mounted // TODO: reclaim the superseded generation once the last agent joined to // it is gone. The subtree is not inert — `dsh-skill-filesystem` watches its @@ -763,17 +806,25 @@ export class AgentPresets extends TypertRemoteService { // decremented when the agent's scope key dies. // Guarded delete: a caller that raced this one may have already started // the next generation, and dropping THAT pointer would fork a third. - if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id) + if (this.standing.get(preset.id) === pending) { + this.standing.delete(preset.id) + this.retire(preset.id, mounted) + } return this.ensureStanding(preset) } const created = (async (): Promise => { + // Stamped before the file is read: an edit racing the mount makes the + // stamp stale rather than silently current, so the next session + // refreshes instead of trusting a composition older than its stamp. + const stamp = await compositionStamp(preset) + const reused = stamp === undefined ? undefined : this.retired.get(preset.id)?.get(stampKey(stamp)) + if (reused !== undefined) { + this.retired.get(preset.id)?.delete(stampKey(reused.stamp)) + return reused + } const key: ScopeKey = { agentPreset: preset.id } const scope = createScope(this.selfCtx, key) try { - // Stamped before the file is read: an edit racing the mount makes the - // stamp stale rather than silently current, so the next session - // refreshes instead of trusting a composition older than its stamp. - const stamp = await compositionStamp(preset.path) if (stamp === undefined) { const reason = `composition file is unreadable: ${preset.path}` throw new RemoteError( @@ -793,21 +844,42 @@ export class AgentPresets extends TypertRemoteService { this.standing.set(preset.id, created) return created } + + /** File a superseded generation under its stamp, for a later edit that restores its content. */ + private retire(presetId: string, mount: StandingMount): void { + let generations = this.retired.get(presetId) + if (generations === undefined) { + generations = new Map() + this.retired.set(presetId, generations) + } + generations.set(stampKey(mount.stamp), mount) + } } -/** The composition file identity one standing generation was mounted from. */ +/** + * The composition identity one standing generation was mounted from: the + * composition file's stat, and the user patch layer's content — content + * rather than stat, so a layer edited back to what an earlier generation + * composed identifies that generation. + */ interface CompositionStamp { /** Modification time in milliseconds, as `stat` reports it. */ readonly mtimeMs: number /** File size in bytes, the tiebreak for edits within one mtime tick. */ readonly size: number + /** Digest of the user patch layer's text; empty when the preset has none. */ + readonly overlay: string } -/** Read one composition file's stamp, or undefined when it cannot be statted. */ -async function compositionStamp(path: string): Promise { +/** + * Read one preset's stamp, or undefined when its composition cannot be statted. + * A layer that cannot be read stamps as absent: the mount then composes the + * bare preset, which is also what an unreadable layer applies. + */ +async function compositionStamp(preset: AgentPreset): Promise { try { - const { mtimeMs, size } = await stat(path) - return { mtimeMs, size } + const { mtimeMs, size } = await stat(preset.path) + return { mtimeMs, size, overlay: await overlayDigest(preset.overlayPath) } } catch { // Deleted, replaced by an unreadable entry, or otherwise unstattable all // mean the same to the caller: the file offers no identity to compare. @@ -815,9 +887,43 @@ async function compositionStamp(path: string): Promise { + if (overlayPath === undefined) return '' + let text: string + try { + text = await readFile(overlayPath, 'utf8') + } catch { + // A layer deleted since discovery is a layer that applies nothing. + return '' + } + return createHash('sha1').update(text).digest('hex') +} + +/** Whether two stamps name the same composition state. */ function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean { - return a.mtimeMs === b.mtimeMs && a.size === b.size + return a.mtimeMs === b.mtimeMs && a.size === b.size && a.overlay === b.overlay +} + +/** One stamp as a map key. */ +function stampKey(stamp: CompositionStamp): string { + return `${String(stamp.mtimeMs)}:${String(stamp.size)}:${stamp.overlay}` +} + +/** + * The preset's user patch layer as parsed, or empty when it has none or the + * layer cannot be read — the inventory then reports the rows the bare + * composition holds, which is also what such a layer applies. + */ +async function readOverlay(preset: AgentPreset): Promise { + if (preset.overlayPath === undefined) return [] + try { + return await readPatchListFile('agent-presets', preset.overlayPath, 'user patch layer') ?? [] + } catch { + // Discovery already reported an unparsable layer as the preset's health; + // the inventory answers with the composition alone. + return [] + } } /** One preset's standing composition. */ diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 1e273ba950..5b9fa75045 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -20,6 +20,7 @@ import { Include } from '@deepseek-ai/cordis-plugin-include' import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader' import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { readPatchListFile } from '@deepseek-ai/dsh-patch-file' import type { AgentPreset } from './preset.ts' import { classifyRowSpecifier } from './specifier.ts' @@ -368,12 +369,19 @@ function mountDetail(error: unknown): string { /** * Mount `preset` under `agentCtx` and return only once every row is usable. * + * The preset's user patch layer, when it has one, is read here and handed to + * the include as its runtime patches, so the layer is applied over the + * composition with the Loader's own patch semantics — a `disabled: true` + * switches a row off, a `config` replaces the row's, an `insert` adds rows — + * and re-read at every mount, which is what makes a generation follow the + * layer file. + * * The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and * the caller receives no disposer. A rejection leaves nothing mounted. * @param agentCtx - the agent's scope context, from the agent factory's `setup`. * @param preset - the resolved preset to compose the agent from. - * @throws when `agentCtx` carries no scope, a row is unusable, or a row - * published a service into the root realm. + * @throws when `agentCtx` carries no scope, the user patch layer cannot be + * read, a row is unusable, or a row published a service into the root realm. */ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise { const scope = scopeOf(agentCtx) @@ -383,7 +391,13 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi + 'its registrations would apply to every agent in the process', ) } - const config: Include.Config = { path: pathToFileURL(preset.path).href } + const patches = preset.overlayPath === undefined + ? undefined + : await readPatchListFile('agent-presets', preset.overlayPath, 'user patch layer') + const config: Include.Config = { + path: pathToFileURL(preset.path).href, + ...patches === undefined ? {} : { patches }, + } // Captured before the subtree exists: the standing scope context still // carries the host composition's base, which is inside the installed // harness and is therefore where a row's package name has to resolve from. diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index 5ffa58aee4..25b04a802f 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -25,6 +25,14 @@ export interface AgentPreset { readonly trust: PresetTrust /** Absolute path of the preset's agent composition file. */ readonly path: string + /** + * Absolute path of the preset's user patch layer (`cordis.patch.yml`), when + * one exists: beside the composition for a locally authored preset, or in + * the user root's directory of the same id for a shipped one. Applied over + * the composition at every mount, so a person can disable or add a row + * without editing the file the deployment ships. + */ + readonly overlayPath?: string /** Display name from the preset's own metadata; absent falls back to {@link id}. */ readonly name?: string /** One sentence on what this preset is for, when it published one. */ diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts index fa6cdf596a..cee110d86d 100644 --- a/packages/preset/agent-presets/tests/composition-inventory.spec.ts +++ b/packages/preset/agent-presets/tests/composition-inventory.spec.ts @@ -104,20 +104,21 @@ describe('fileComposition', () => { expect(await fileComposition(path, refuseExpression)).toEqual({ rows: [ - { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true }, - { entryId: null, moduleName: 'pkg-anonymous', enabled: true }, - { entryId: 'off', moduleName: 'pkg-off', enabled: false }, + { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, source: 'preset' }, + { entryId: null, moduleName: 'pkg-anonymous', enabled: true, source: 'preset' }, + { entryId: 'off', moduleName: 'pkg-off', enabled: false, source: 'preset', disabledBy: 'composition' }, { entryId: 'cond', moduleName: 'pkg-cond', enabled: 'conditional', + source: 'preset', condition: 'process.platform === \'win32\'', }, - { entryId: 'child', moduleName: 'pkg-child', enabled: true }, - { entryId: 'child-off', moduleName: 'pkg-child-off', enabled: false }, - { entryId: 'buried', moduleName: 'pkg-buried', enabled: false }, - { entryId: 'maybe', moduleName: 'pkg-maybe', enabled: 'conditional' }, - { entryId: 'certainly-off', moduleName: 'pkg-certainly-off', enabled: false }, + { entryId: 'child', moduleName: 'pkg-child', enabled: true, source: 'preset' }, + { entryId: 'child-off', moduleName: 'pkg-child-off', enabled: false, source: 'preset', disabledBy: 'composition' }, + { entryId: 'buried', moduleName: 'pkg-buried', enabled: false, source: 'preset', disabledBy: 'composition' }, + { entryId: 'maybe', moduleName: 'pkg-maybe', enabled: 'conditional', source: 'preset' }, + { entryId: 'certainly-off', moduleName: 'pkg-certainly-off', enabled: false, source: 'preset', disabledBy: 'composition' }, ], }) }) @@ -136,8 +137,8 @@ describe('fileComposition', () => { expect(await fileComposition(path, evaluateExpression)).toEqual({ rows: [ - { entryId: 'off', moduleName: 'pkg-off', enabled: false, condition: '1 === 1' }, - { entryId: 'on', moduleName: 'pkg-on', enabled: true, condition: '1 === 2' }, + { entryId: 'off', moduleName: 'pkg-off', enabled: false, condition: '1 === 1', source: 'preset', disabledBy: 'composition' }, + { entryId: 'on', moduleName: 'pkg-on', enabled: true, condition: '1 === 2', source: 'preset' }, ], }) }) @@ -185,13 +186,14 @@ describe('mountedCompositionRows', () => { const byId = new Map(rows.map(row => [row.entryId, row])) expect(rows).toHaveLength(3) expect(byId.get(activeId)).toEqual( - { entryId: activeId, moduleName: 'cordis:active', enabled: true, fiberState: FiberState.ACTIVE }) + { entryId: activeId, moduleName: 'cordis:active', enabled: true, fiberState: FiberState.ACTIVE, source: 'preset' }) expect(byId.get(disabledId)).toEqual( - { entryId: disabledId, moduleName: 'cordis:active', enabled: false }) + { entryId: disabledId, moduleName: 'cordis:active', enabled: false, source: 'preset', disabledBy: 'composition' }) expect(byId.get(evaluatedId)).toEqual({ entryId: evaluatedId, moduleName: 'cordis:active', enabled: true, + source: 'preset', condition: 'false', fiberState: FiberState.ACTIVE, }) @@ -224,15 +226,15 @@ describe('AgentPresets.compositionInventory', () => { id: 'minimal', trust: 'system', isDefault: true, - rows: [{ entryId: 'beta', moduleName: '../../plugins/contribute.js', enabled: true }], + rows: [{ entryId: 'beta', moduleName: '../../plugins/contribute.js', enabled: true, source: 'preset' }], }, { id: 'standard', trust: 'system', isDefault: false, rows: [ - { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true }, - { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, + { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true, source: 'preset' }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' }, ], }, { @@ -241,15 +243,16 @@ describe('AgentPresets.compositionInventory', () => { name: '我的模式', isDefault: false, rows: [ - { entryId: 'prompt', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: true }, + { entryId: 'prompt', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: true, source: 'preset' }, // The platform-gate shape: the service evaluates it with the // Loader's own scope, so the file answer matches a mount's. - { entryId: 'gated', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: false, condition: '1 === 1' }, + { entryId: 'gated', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: false, condition: '1 === 1', source: 'preset', disabledBy: 'composition' }, // An expression the evaluator refuses stays a mount's decision. { entryId: 'undecidable', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: 'conditional', + source: 'preset', condition: 'nothing.here', }, ], @@ -279,9 +282,10 @@ describe('AgentPresets.compositionInventory', () => { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true, + source: 'preset', fiberState: FiberState.ACTIVE, }, - { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' }, ]) }) @@ -311,7 +315,7 @@ describe('AgentPresets.compositionInventory', () => { expect(volatile).toMatchObject({ id: 'volatile', trust: 'user', isDefault: true }) expect(volatile?.broken).toBeUndefined() expect(volatile?.rows).toEqual([ - { entryId: 'only', moduleName: plugin, enabled: true, fiberState: FiberState.ACTIVE }, + { entryId: 'only', moduleName: plugin, enabled: true, fiberState: FiberState.ACTIVE, source: 'preset' }, ]) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 9d5d052724..8c970b6a99 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -726,7 +726,7 @@ describe('editing a composition file', () => { standing: Map> ensureStanding(current: typeof preset): Promise } @@ -734,7 +734,7 @@ describe('editing a composition file', () => { const stale = await stalePromise await writeFile(path, rowFor('afterwards')) const { mtimeMs, size } = await stat(path) - const newer = { ...stale, stamp: { mtimeMs, size } } + const newer = { ...stale, stamp: { mtimeMs, size, overlay: '' } } const newerPromise = Promise.resolve(newer) // `await pending` yields before the guarded delete, letting the winning diff --git a/packages/preset/agent-presets/tests/overlay.spec.ts b/packages/preset/agent-presets/tests/overlay.spec.ts new file mode 100644 index 0000000000..38747e219b --- /dev/null +++ b/packages/preset/agent-presets/tests/overlay.spec.ts @@ -0,0 +1,342 @@ +/** + * The per-preset user patch layer: a `cordis.patch.yml` beside a locally + * authored composition, or alone in the user root's slot of a shipped + * preset's id. Discovery attaches it and judges it, the mount applies it, + * generations follow its content, the inventory reports its decisions, a + * copy carries it, and authoring can remove it. + */ + +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context, FiberState } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import Group from '@deepseek-ai/cordis-plugin-group' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { afterEach, describe, expect, it } from 'vitest' +import AgentPresets, { + COMPOSITION_FILE, discoverPresets, livePresetMounts, overlayFacts, OVERLAY_FILE, scanRoot, +} from '@deepseek-ai/dsh-agent-presets' +import type { Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const HARNESS = new URL('.', import.meta.url).href +const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } +const CONTRIBUTE = join(FIXTURES, 'plugins', 'contribute.js') + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +/** A fresh user root, optionally holding one slot with the given files. */ +async function userRoot(slots: Record> = {}): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-overlay-root-')) + for (const [id, files] of Object.entries(slots)) { + await mkdir(join(root, id), { recursive: true }) + for (const [file, content] of Object.entries(files)) await writeFile(join(root, id, file), content) + } + return root +} + +/** An overlay that switches `alpha` off and inserts one row registering `tool`. */ +function overlayInserting(tool: string): string { + return [ + '- id: alpha', + ' disabled: true', + '- insert:', + ` - id: user-${tool}`, + ` name: ${CONTRIBUTE}`, + ' config:', + ` tool: ${tool}`, + '', + ].join('\n') +} + +async function harness(roster: Config): Promise { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.loader.builtins.group = Group + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, roster) + return ctx +} + +async function agentOn(ctx: Context, id: string, presetId: string): Promise { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId), + }) + return handle.agent +} + +const toolNames = (ctx: Context, agent: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +const rosterOver = (root: string, extra: Partial = {}): Config => ({ + default: 'standard', + roots: [SYSTEM, { path: root, trust: 'user' }], + includeShippedRoot: false, + includeUserRoot: false, + ...extra, +}) + +describe('discovery', () => { + it('attaches a lone layer in a later root to the preset that won the id', async () => { + const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } }) + + const presets = await discoverPresets([SYSTEM, { path: root, trust: 'user' }], HARNESS) + + const standard = presets.find(preset => preset.id === 'standard') + expect(standard).toMatchObject({ trust: 'system', overlayPath: join(root, 'standard', OVERLAY_FILE) }) + expect(standard?.broken).toBeUndefined() + // The lone layer is not a preset of its own root. + expect(await scanRoot({ path: root, trust: 'user' }, HARNESS)).toEqual([]) + }) + + it('reads a layer beside a locally authored composition as that preset\'s own', async () => { + const root = await userRoot({ + mine: { + [COMPOSITION_FILE]: `- id: only\n name: ${CONTRIBUTE}\n config:\n tool: only\n`, + [OVERLAY_FILE]: '- id: only\n disabled: true\n', + }, + }) + const other = await userRoot({ mine: { [OVERLAY_FILE]: '- id: only\n config: {}\n' } }) + + const presets = await discoverPresets([{ path: root, trust: 'user' }, { path: other, trust: 'user' }], HARNESS) + + // The preset's own layer wins; a later root's lone layer for the same id is ignored. + expect(presets).toEqual([expect.objectContaining({ id: 'mine', overlayPath: join(root, 'mine', OVERLAY_FILE) })]) + }) + + it('reports a lone layer whose id no root supplies as a broken slot', async () => { + const root = await userRoot({ ghost: { [OVERLAY_FILE]: '[]\n' } }) + + const presets = await discoverPresets([SYSTEM, { path: root, trust: 'user' }], HARNESS) + + expect(presets.find(preset => preset.id === 'ghost')).toMatchObject({ + trust: 'user', + path: join(root, 'ghost', COMPOSITION_FILE), + overlayPath: join(root, 'ghost', OVERLAY_FILE), + broken: expect.stringContaining('no root supplies a preset "ghost"') as string, + }) + // A slot with neither file is still the missing-composition case. + const empty = await userRoot({ hollow: { 'notes.txt': '' } }) + const hollow = (await discoverPresets([{ path: empty, trust: 'user' }], HARNESS)).find(preset => preset.id === 'hollow') + expect(hollow?.broken).toContain(`${COMPOSITION_FILE} is missing`) + }) + + it('judges the layer with the composition: unparsable, malformed, or unresolvable inserts break the preset', async () => { + const unparsable = await userRoot({ standard: { [OVERLAY_FILE]: 'a: [\n' } }) + const malformed = await userRoot({ standard: { [OVERLAY_FILE]: '- insert:\n - config: {}\n' } }) + const unresolvable = await userRoot({ standard: { [OVERLAY_FILE]: '- insert:\n - id: x\n name: ./nowhere.js\n' } }) + const own = await userRoot({ + mine: { [COMPOSITION_FILE]: '[]\n', [OVERLAY_FILE]: 'a: 1\n' }, + }) + + const [a, b, c, d] = await Promise.all([unparsable, malformed, unresolvable].map( + async root => (await discoverPresets([SYSTEM, { path: root, trust: 'user' }], HARNESS)).find(preset => preset.id === 'standard'), + ).concat([ + (async () => (await discoverPresets([{ path: own, trust: 'user' }], HARNESS)).find(preset => preset.id === 'mine'))(), + ])) + + expect(a?.broken).toContain(`the user patch layer ${OVERLAY_FILE} cannot be applied`) + expect(b?.broken).toContain('user patch layer entry 1 row 1 names no plugin') + expect(c?.broken).toContain('inserts row "x", which names a plugin that cannot be resolved') + expect(d?.broken).toContain('must be a top-level YAML array') + }) + + it('leaves a broken composition\'s verdict in place rather than judging its layer', async () => { + const shipped = await userRoot({ bad: { [COMPOSITION_FILE]: '- id: x\n name: ./nowhere.js\n' } }) + const root = await userRoot({ bad: { [OVERLAY_FILE]: 'a: [\n' } }) + + const presets = await discoverPresets([{ path: shipped, trust: 'system' }, { path: root, trust: 'user' }], HARNESS) + + const broken = presets.find(preset => preset.id === 'bad') + expect(broken?.overlayPath).toBe(join(root, 'bad', OVERLAY_FILE)) + expect(broken?.broken).toContain('cannot be resolved') + expect(broken?.broken).not.toContain('user patch layer') + }) +}) + +describe('mounting', () => { + it('applies the layer over the composition: a row switched off, a row added', async () => { + const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } }) + const ctx = await harness(rosterOver(root)) + + const agent = await agentOn(ctx, 'sess-overlaid', 'standard') + + expect(toolNames(ctx, agent)).toEqual(['gamma']) + }) + + it('starts a new generation when the layer changes and returns to one whose content comes back', async () => { + const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } }) + const ctx = await harness(rosterOver(root)) + const first = await agentOn(ctx, 'sess-gen-1', 'standard') + const firstMount = livePresetMounts().findLast(mount => mount.presetId === 'standard') + + await writeFile(join(root, 'standard', OVERLAY_FILE), overlayInserting('delta')) + const second = await agentOn(ctx, 'sess-gen-2', 'standard') + const secondMount = livePresetMounts().findLast(mount => mount.presetId === 'standard') + + expect(toolNames(ctx, first)).toEqual(['gamma']) + expect(toolNames(ctx, second)).toEqual(['delta']) + expect(secondMount).not.toBe(firstMount) + expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(2) + + // The same content again: the retired generation serves, no third mount. + await writeFile(join(root, 'standard', OVERLAY_FILE), overlayInserting('gamma')) + const third = await agentOn(ctx, 'sess-gen-3', 'standard') + expect(toolNames(ctx, third)).toEqual(['gamma']) + expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(2) + expect(await ctx.agentPresets.standingKeyFor('standard')).toBe(firstMount?.key) + }) + + it('composes the bare preset when the layer disappears between discovery and mount', async () => { + const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } }) + const ctx = await harness(rosterOver(root)) + const preset = await ctx.agentPresets.resolve('standard') + await writeFile(join(root, 'standard', OVERLAY_FILE), '[]\n') + + const racer = ctx.agentPresets as unknown as { ensureStanding(current: typeof preset): Promise } + await racer.ensureStanding(preset) + const agent = await agentOn(ctx, 'sess-bare', 'standard') + + expect(toolNames(ctx, agent)).toEqual(['alpha']) + }) +}) + +describe('inventory', () => { + it('reports the layer\'s decisions from the file and from a standing mount', async () => { + const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } }) + const ctx = await harness(rosterOver(root)) + + const fromFile = (await ctx.agentPresets.compositionInventory()).find(composition => composition.id === 'standard') + expect(fromFile?.rows).toEqual([ + { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' }, + { entryId: 'user-gamma', moduleName: CONTRIBUTE, enabled: true, source: 'user' }, + ]) + + await agentOn(ctx, 'sess-inventory', 'standard') + const mounted = (await ctx.agentPresets.compositionInventory()).find(composition => composition.id === 'standard') + expect(mounted?.rows).toEqual([ + { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' }, + { entryId: 'user-gamma', moduleName: CONTRIBUTE, enabled: true, source: 'user', fiberState: FiberState.ACTIVE }, + ]) + }) + + it('answers from the composition alone when the layer stopped reading', async () => { + const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } }) + const ctx = await harness(rosterOver(root)) + await agentOn(ctx, 'sess-inventory-broken-layer', 'standard') + await writeFile(join(root, 'standard', OVERLAY_FILE), 'a: [\n') + + const mounted = (await ctx.agentPresets.compositionInventory()).find(composition => composition.id === 'standard') + + expect(mounted?.rows.map(row => [row.entryId, row.source, row.disabledBy])).toEqual([ + ['alpha', 'preset', 'composition'], + ['alpha-extra', 'preset', 'composition'], + ['user-gamma', 'preset', undefined], + ]) + }) + + it('reads a layer\'s inserted and disabled ids, ignoring !!js gates', () => { + expect(overlayFacts([ + { id: 'a', disabled: true }, + { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean }, + { id: 'c', config: {} }, + { insert: [{ id: 'd', name: 'x' }, { id: 'g', name: 'cordis:group', group: true, config: [{ id: 'e', name: 'y' }] }] }, + ])).toEqual({ inserted: new Set(['d', 'g', 'e']), disabled: new Set(['a']) }) + }) +}) + +describe('authoring', () => { + it('names the layer path for shipped and locally authored presets', async () => { + const root = await userRoot({ + mine: { [COMPOSITION_FILE]: `- id: only\n name: ${CONTRIBUTE}\n config:\n tool: only\n` }, + }) + const ctx = await harness(rosterOver(root)) + + expect(await ctx.agentPresets.overlayPathFor('standard')).toBe(join(root, 'standard', OVERLAY_FILE)) + expect(await ctx.agentPresets.overlayPathFor('mine')).toBe(join(root, 'mine', OVERLAY_FILE)) + await writeFile(join(root, 'mine', OVERLAY_FILE), '[]\n') + expect(await ctx.agentPresets.overlayPathFor('mine')).toBe(join(root, 'mine', OVERLAY_FILE)) + }) + + it('refuses a layer path when the deployment configures no writable root', async () => { + const ctx = await harness({ default: 'standard', roots: [SYSTEM], includeShippedRoot: false, includeUserRoot: false }) + + await expect(ctx.agentPresets.overlayPathFor('standard')).rejects.toMatchObject({ code: 'agent-preset/read-only' }) + }) + + it('copies a shipped preset\'s layer in beside the new composition', async () => { + // A shipped preset whose rows resolve from anywhere, so its copy mounts. + const shipped = await userRoot({ + base: { [COMPOSITION_FILE]: `- id: alpha\n name: ${CONTRIBUTE}\n config:\n tool: alpha\n` }, + }) + const root = await userRoot({ base: { [OVERLAY_FILE]: overlayInserting('gamma') } }) + const ctx = await harness({ + default: 'base', + roots: [{ path: shipped, trust: 'system' }, { path: root, trust: 'user' }], + includeShippedRoot: false, + includeUserRoot: false, + }) + + await ctx.agentPresets.copy('base', 'research', 'Research') + + expect(await readFile(join(root, 'research', OVERLAY_FILE), 'utf8')).toBe(overlayInserting('gamma')) + const research = await ctx.agentPresets.resolve('research') + expect(research.overlayPath).toBe(join(root, 'research', OVERLAY_FILE)) + const agent = await agentOn(ctx, 'sess-copied', 'research') + expect(toolNames(ctx, agent)).toEqual(['gamma']) + }) + + it('removes a layer and the slot it alone occupied', async () => { + const root = await userRoot({ + standard: { [OVERLAY_FILE]: overlayInserting('gamma') }, + mine: { [COMPOSITION_FILE]: '[]\n', [OVERLAY_FILE]: '[]\n' }, + }) + const ctx = await harness(rosterOver(root)) + + expect(await ctx.agentPresets.removeOverlay('standard')).toBe(true) + expect(existsSync(join(root, 'standard'))).toBe(false) + expect(await ctx.agentPresets.removeOverlay('standard')).toBe(false) + expect(await ctx.agentPresets.removeOverlay('mine')).toBe(true) + expect(existsSync(join(root, 'mine', COMPOSITION_FILE))).toBe(true) + expect((await ctx.agentPresets.resolve('standard')).overlayPath).toBeUndefined() + }) + + it('refuses to remove a layer outside the writable root', async () => { + const shipped = await userRoot({ standard: { [OVERLAY_FILE]: '[]\n' } }) + const writable = await userRoot() + const ctx = await harness({ + default: 'standard', + roots: [SYSTEM, { path: shipped, trust: 'system' }, { path: writable, trust: 'user' }], + includeShippedRoot: false, + includeUserRoot: false, + }) + + await expect(ctx.agentPresets.removeOverlay('standard')).rejects.toMatchObject({ code: 'agent-preset/read-only' }) + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index c357b1f257..3a870a98c2 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -51,6 +51,9 @@ { "path": "../../util/home-paths" }, + { + "path": "../../util/patch-file" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/preset/global-tool-mask/README.i18n.yaml b/packages/preset/global-tool-mask/README.i18n.yaml new file mode 100644 index 0000000000..4ae64c6048 --- /dev/null +++ b/packages/preset/global-tool-mask/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/preset/global-tool-mask/README.md +README.md: 8c0b9dba5ac932f9d531323fcfde016b0029a7d2 +README.zh.md: 9e65f12f8f2636bc33b24ba4a2a751474019f3be diff --git a/packages/preset/global-tool-mask/README.md b/packages/preset/global-tool-mask/README.md new file mode 100644 index 0000000000..8c0b9dba5a --- /dev/null +++ b/packages/preset/global-tool-mask/README.md @@ -0,0 +1,98 @@ +--- +description: "Composition-authored global-tool mask for one agent preset: a row that hides named host tools from the sessions the preset composes." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-global-tool-mask + +English | [中文](README.zh.md) + +## Summary + +`dsh-global-tool-mask` is `tools.restrict()` as a composition row. The host's global tools — the ones a host row registers, such as `web_fetch` — reach every session through the global layer, and nothing in a preset file could hide one until now: a preset can add rows, not subtract tools other rows registered. Mounted inside an agent preset, this row masks the named global tools for the sessions that preset composes and nothing else; mounted globally it rejects, because a context-global restriction would mask every agent. Its intended home is a preset's user patch layer (`$DSH_HOME/.agent-presets//cordis.patch.yml`), where a person hides a host tool from one preset without editing the composition the deployment ships. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Insert one row into a preset's composition or its user patch layer. `deny` hides the named tools; `allow` keeps only the named tools; both together intersect: + +```yaml +- insert: + - id: hide-web + name: '@deepseek-ai/dsh-global-tool-mask' + config: + deny: [web_fetch, web_search] +``` + +The mask follows the row's fiber: it applies when the preset's standing composition mounts and lifts when that composition is torn down. The names are checked at mount, so a mask that names no tool, or names one the host does not register, fails the preset loud with the registry's own message rather than silently masking nothing. Scoped registrations — tools the preset's own rows register — are never affected; the reserved PTC transport name cannot be masked either. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The row is one `ctx.effect()` around `ctx.tools.restrict(config)`. Every rule — scoped context required, non-empty filter, known names, the reserved transport name — belongs to the registry and is enforced there; the row adds a `Config` schema so a hand-written composition is validated at load. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | The `global-tool-mask` function plugin: `name`, `inject`, `Config`, `apply` | +| — | No runtime invariant companion is published; the registry owns the restriction's lifetime and reports the mask through its own views. | + +
+ +----- + + +## Further Exploration + +Read these when the question is what the mask acts on or where the row lives. + +- [Tool registry](../../core/tools/README.md) — `tools.restrict()`, the layered views, and why a restriction requires a scope. +- [Agent presets](../agent-presets/README.md) — the compositions and user patch layers this row is inserted into. + +----- + + +## Model Experience + +Indirectly, through the tool registry: the row removes tool schemas from the request the registry assembles for the scoped agent and registers no prompt, schema, or result of its own. + +#### KV Cache effect + +None of its own; the registry assembles the tool list, and a mask changes which schemas that list carries for the sessions of one preset from their first request onward. + +## Known Limitations and Deferred Work + + + + +These limits define what the row will not do. They are current package constraints, not a task backlog. + +- **Global tools only** — the mask cannot hide a tool a row of the same preset registers; disable that row instead. +- **Mount-time names** — a tool registered after the preset mounted is not retroactively judged: a name the host adds later cannot have been in the mask, and a name the host stops registering leaves the mask as it was. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/preset/global-tool-mask/README.zh.md b/packages/preset/global-tool-mask/README.zh.md new file mode 100644 index 0000000000..9e65f12f8f --- /dev/null +++ b/packages/preset/global-tool-mask/README.zh.md @@ -0,0 +1,98 @@ +--- +description: "面向单个 agent preset 的、由组合书写的全局工具掩码:一行把点名的宿主工具对该 preset 组合出的会话隐藏起来。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-global-tool-mask + +[English](README.md) | 中文 + +## 概述 + +`dsh-global-tool-mask` 是写成组合行的 `tools.restrict()`。宿主的全局工具——由宿主行注册的那些,如 `web_fetch`——经全局层到达每一个会话,而在此之前 preset 文件里没有任何东西能藏起其中一个:preset 能加行,却减不掉别的行注册的工具。挂在 agent preset 内部时,本行只对该 preset 组合出的会话遮蔽点名的全局工具,别的一概不动;挂在全局则拒绝,因为一个上下文全局的限制会遮住每一个 agent。它预期的落点是 preset 的用户补丁层(`$DSH_HOME/.agent-presets//cordis.patch.yml`):一个人不必编辑部署随附的组合,就能把一个宿主工具对某个 preset 藏起来。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +往 preset 的组合或其用户补丁层插入一行。`deny` 隐藏点名的工具;`allow` 只保留点名的工具;两者同时给出时取交集: + +```yaml +- insert: + - id: hide-web + name: '@deepseek-ai/dsh-global-tool-mask' + config: + deny: [web_fetch, web_search] +``` + +掩码跟随本行的 fiber:preset 的常驻组合挂载时生效,该组合被拆除时解除。名字在挂载时检查,因此一个没点名任何工具、或点名了宿主未注册工具的掩码会带着注册表自己的消息让 preset 大声失败,而不是悄悄什么都不遮。作用域内的注册——preset 自己的行注册的工具——永远不受影响;保留的 PTC 传输名也不能被遮蔽。 + +----- + + +## 理解实现 + +
+实现内幕——点击展开 + +本行就是包着 `ctx.tools.restrict(config)` 的一个 `ctx.effect()`。每条规则——必须是作用域上下文、过滤器非空、名字已知、保留的传输名——都属于注册表并在那里执行;本行只增加一个 `Config` schema,让手写的组合在加载时得到校验。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `global-tool-mask` 函数插件:`name`、`inject`、`Config`、`apply` | +| — | 不发布运行时不变量伴随件;注册表拥有限制的生命周期,并通过自己的视图报告掩码。 | + +
+ +----- + + +## 进一步探索 + +当问题在于掩码作用于什么、或这一行放在哪里时,读这些。 + +- [工具注册表](../../core/tools/README.zh.md)——`tools.restrict()`、分层视图,以及限制为何需要作用域。 +- [Agent presets](../agent-presets/README.zh.md)——本行被插入其中的组合与用户补丁层。 + +----- + + +## 模型体验 + +间接地,经由工具注册表:本行从注册表为作用域内 agent 组装的请求中移除工具 schema,自身不注册任何提示词、schema 或结果。 + +#### KV Cache 影响 + +自身无;工具列表由注册表组装,掩码改变的是该列表为某个 preset 的会话从首次请求起携带哪些 schema。 + +## 已知限制与延期工作 + + + + +这些限制界定本行不会做什么。它们是当前包的约束,不是任务清单。 + +- **仅限全局工具**——掩码藏不住同一 preset 中某行注册的工具;请改为停用那一行。 +- **名字在挂载时判定**——preset 挂载后才注册的工具不会被追溯判定:宿主之后新增的名字不可能出现在掩码里,宿主不再注册的名字也让掩码保持原样。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/preset/global-tool-mask/package.json b/packages/preset/global-tool-mask/package.json new file mode 100644 index 0000000000..7a0dcaf5fb --- /dev/null +++ b/packages/preset/global-tool-mask/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-global-tool-mask", + "description": "Composition-authored global-tool mask for one agent preset: a row that hides named host tools from the sessions the preset composes", + "version": "0.1.2-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/preset/global-tool-mask" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/preset/global-tool-mask/src/index.ts b/packages/preset/global-tool-mask/src/index.ts new file mode 100644 index 0000000000..2dd8a15206 --- /dev/null +++ b/packages/preset/global-tool-mask/src/index.ts @@ -0,0 +1,54 @@ +/** + * A global-tool mask as a composable row. + * + * `dsh-tools` lets an agent scope restrict the host's global tools through + * `tools.restrict()`, and nothing in a composition file could call it: a + * preset can add rows, but the tools a host row registers reach every + * session through the global layer. This row is that call as a row — + * **scope-only**, like `dsh-persona`: mounted inside an agent preset it hides + * the named global tools from the sessions the preset composes; mounted + * globally it rejects, because a context-global restriction would mask every + * agent. A preset's user patch layer is where the row is meant to land, so a + * person can hide a host tool from one preset without editing the file the + * deployment ships. + * @module @deepseek-ai/dsh-global-tool-mask + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-tools' + +/** Cordis plugin name. */ +export const name = 'global-tool-mask' + +/** The registry this row masks. */ +export const inject = ['tools'] + +/** Plugin config: the global-tool mask, in `tools.restrict()`'s own vocabulary. */ +export interface Config { + /** Global tool names that stay visible; everything else is hidden. Empty means no allow list. */ + allow: string[] + /** Global tool names hidden from this scope. Empty means no deny list. */ + deny: string[] +} + +/** Runtime schema for the mask row. */ +export const Config: z = z.object({ + allow: z.array(z.string()), + deny: z.array(z.string()), +}) + +/** + * Restrict the global tools visible to the mounting context's scope. + * @param ctx - an agent scope context; an unscoped context rejects, as does + * a mask that names no tool or names one the host does not register. + * @param config - the mask; `allow` and `deny` intersect when both are given. + * An empty list is the same as an absent one: the schema materializes a + * missing list as `[]`, and an empty `allow` would otherwise hide every tool. + */ +export function apply(ctx: Context, config: Config): void { + ctx.effect(() => ctx.tools.restrict({ + ...config.allow.length === 0 ? {} : { allow: config.allow }, + ...config.deny.length === 0 ? {} : { deny: config.deny }, + }), 'global-tool-mask.restrict()') +} diff --git a/packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts b/packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts new file mode 100644 index 0000000000..12a76a26f5 --- /dev/null +++ b/packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts @@ -0,0 +1,71 @@ +import { Context } from '@deepseek-ai/cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { describe, expect, it } from 'vitest' +import * as ToolRestrict from '@deepseek-ai/dsh-global-tool-mask' + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + for (const name of ['alpha', 'beta', 'gamma']) { + ctx.tools.register({ + name, + description: `fixture tool ${name}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] }, + execute: () => Promise.resolve(name), + }) + } + return ctx +} + +const visible = (ctx: Context, scope?: ScopeKey): string[] => + ctx.tools.schemas(scope).map(schema => schema.name).sort() + +describe('the global-tool-mask row', () => { + it('rejects an unscoped mount, which would mask every agent', async () => { + const ctx = await harness() + + await expect(ctx.plugin(ToolRestrict, { allow: [], deny: ['alpha'] })).rejects.toThrow(/requires a scoped context/) + }) + + it('hides denied global tools from one scope only', async () => { + const ctx = await harness() + const key: ScopeKey = { agent: 'a1' } + + await createScope(ctx, key).ctx.plugin(ToolRestrict, { allow: [], deny: ['alpha'] }) + + expect(visible(ctx, key)).toEqual(['beta', 'gamma']) + expect(visible(ctx)).toEqual(['alpha', 'beta', 'gamma']) + }) + + it('keeps only allowed tools, intersected with a deny list', async () => { + const ctx = await harness() + const key: ScopeKey = { agent: 'a2' } + + await createScope(ctx, key).ctx.plugin(ToolRestrict, { allow: ['alpha', 'beta'], deny: ['beta'] }) + + expect(visible(ctx, key)).toEqual(['alpha']) + }) + + it('fails loud on an empty mask and on a tool the host does not register', async () => { + const ctx = await harness() + + await expect(createScope(ctx, { agent: 'a3' }).ctx.plugin(ToolRestrict, { allow: [], deny: [] })).rejects.toThrow(/is a no-op/) + await expect(createScope(ctx, { agent: 'a4' }).ctx.plugin(ToolRestrict, { allow: [], deny: ['nope'] })) + .rejects.toThrow(/unknown global tool "nope"/) + }) + + it('lifts the mask when its fiber unloads', async () => { + const ctx = await harness() + const key: ScopeKey = { agent: 'a5' } + const fiber = await createScope(ctx, key).ctx.plugin(ToolRestrict, { allow: [], deny: ['alpha'] }) + expect(visible(ctx, key)).toEqual(['beta', 'gamma']) + + await fiber.dispose() + + expect(visible(ctx, key)).toEqual(['alpha', 'beta', 'gamma']) + }) +}) diff --git a/packages/preset/global-tool-mask/tsconfig.json b/packages/preset/global-tool-mask/tsconfig.json new file mode 100644 index 0000000000..366174463a --- /dev/null +++ b/packages/preset/global-tool-mask/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/packages/util/patch-file/README.i18n.yaml b/packages/util/patch-file/README.i18n.yaml new file mode 100644 index 0000000000..29f5c2116a --- /dev/null +++ b/packages/util/patch-file/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/util/patch-file/README.md +README.md: b2e25491c6633e0e5f190112e7b13e2fa61040f7 +README.zh.md: 427bf9217828a8fc534b27cb620e3da7d7bbee58 diff --git a/packages/util/patch-file/README.md b/packages/util/patch-file/README.md new file mode 100644 index 0000000000..b2e25491c6 --- /dev/null +++ b/packages/util/patch-file/README.md @@ -0,0 +1,112 @@ +--- +description: "Loader patch-list files: the shared parser for cordis.patch.yml layers and a comment-preserving key-level writer with lock, atomic replace, and readback." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-patch-file + +English | [中文](README.zh.md) + +## Summary + +`dsh-patch-file` owns the file format every composition layer of the harness is written in: a `cordis.patch.yml` is a top-level YAML sequence of Loader patch entries — id-targeted overrides such as `disabled: true` or a `config` replacement, and `insert` lists of new rows — in the Loader's own dialect, where a `!!js` scalar is an expression the row's fiber evaluates. `parsePatchList` is the one parser the profile launcher, bundle layers, `--patch` overlays, and agent-preset user layers read through, so a file one of them accepts is a file all of them accept. `PatchDocument` edits such a file at the key level while keeping its comments, blank lines, and `!!js` scalars as the author wrote them, and `mutatePatchFile` commits one edit under the cross-process writer lock, through an atomic replace, and reads the written text back through the parser before reporting what every reader will now load. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Read a patch layer with `parsePatchList` (text in hand) or `readPatchListFile` (absent file reads as `undefined`). Both anchor a relative `insert` row name such as `./plugin.js` to the file's own directory and fail loud on anything that is not a sequence of mappings, because a patch file that cannot be applied at all is a misconfiguration; a patch whose target row is absent stays a per-entry Loader warning. + +Write through `mutatePatchFile`. The callback receives a `PatchDocument` and edits it by row id, the way the Loader addresses rows: + +```ts +await mutatePatchFile(file, (document) => { + document.setRowField('tool-web', 'disabled', true) // the id-targeted patch is created when absent + document.deleteRowField('tool-web', 'config') // a patch reduced to its id is removed whole + document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' }) // into the root list + document.appendInsert({ id: 'sql', name: 'dsh-sql' }, 'agents') // into the group with that id + document.removeInsert('tool-foo') // an emptied insert patch is removed whole +}, { binName: 'dsh', mode: 0o600, dirMode: 0o700 }) +``` + +`setRowField` never accepts `id` or `insert`; `rowField` reads one key back, with a `!!js` scalar returned as its source text. `appendInsert` refuses an id the file already inserts, and `insertedRow`/`removeInsert` find rows inside inserted groups too. Values written are plain data; a `!!js` scalar on another key is left untouched, which is what lets a user layer revert a `disabled: true` it wrote without disturbing a bundle's `!!js` gate on a different row. + +`mutatePatchFile` takes the `.lock` sibling the way `dsh-atomic-write` does, reads the file (absent reads as empty), applies the edit, replaces the file atomically with the stated permission bits when the text changed, and returns the patch list as re-read from the written text. An edit that changes nothing writes nothing. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +### Two parsers, one dialect + +Reading uses `js-yaml` with the include's `entryListSchema`, so `!!js` scalars become the expression nodes the Loader interpolates, exactly as the include mounts them. Writing uses the `yaml` package's comment-preserving `Document`: it keeps the unresolved `!!js` tag on the scalar it decorates (reported as a `TAG_RESOLVE_FAILED` warning, not an error) and prints it back verbatim, so an edit to one key never rewrites another key's expression. The written text is parsed back with the reading parser, which is the readback the writer's contract promises. + +### Addressing + +An id-targeted patch is the top-level item whose `id` matches and that carries no `insert`. An inserted row is searched in every `insert` list, recursing into inserted groups (`group: true` with a `config` list). A top-level item that is not a mapping, or an `insert` whose value is not a list, fails the parse. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | `parsePatchList`, `readPatchListFile`, `anchorInsertedPluginNames`, `PatchDocument`, `mutatePatchFile` | +| — | No runtime invariant companion is published; the package holds no runtime state, and its file contract is pinned by unit tests. | + +
+ +----- + + +## Further Exploration + +Read these when the file format's place in the composition is the question. + +- [App boot](../../boot/app-boot/README.md) — the profile launcher that composes bundle layers, user layers, and overlays from these files. +- [Cordis include plugin](../../../vendor/include/README.md) — `applyEntryPatches`, the patch semantics every layer is applied with. +- [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer written in this format. + +----- + + +## Model Experience + +None, as the package only reads and writes composition patch files; the rows they name own every model-facing registration. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + + +These limits define what the writer will not do to a file. They are current package constraints, not a task backlog. + +- **Key-level, not row-level, merging** — `setRowField('x', 'config', value)` replaces the whole `config` mapping of that patch; a caller that wants one nested field changed reads the current value with `rowField` and writes the merged mapping back. +- **No expression authoring** — the writer emits plain data only; a `!!js` gate is something an author types into the file, never something an API call produces. +- **Lock orphans are an operator action** — a lock file left by a crashed writer is never removed by a contender, which fails after the wait instead; `dsh-atomic-write` documents the same choice. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/util/patch-file/README.zh.md b/packages/util/patch-file/README.zh.md new file mode 100644 index 0000000000..427bf92178 --- /dev/null +++ b/packages/util/patch-file/README.zh.md @@ -0,0 +1,112 @@ +--- +description: "Loader 补丁列表文件:cordis.patch.yml 各层共用的解析器,以及带锁、原子替换与回读的、保留注释的键级写入器。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-patch-file + +[English](README.md) | 中文 + +## 概述 + +`dsh-patch-file` 拥有 harness 每一个组合层所使用的文件格式:一个 `cordis.patch.yml` 是 Loader 补丁条目的顶层 YAML 序列——按 id 定位的覆盖(如 `disabled: true` 或替换 `config`)与插入新行的 `insert` 列表——采用 Loader 自己的方言,其中 `!!js` 标量是由该行 fiber 求值的表达式。`parsePatchList` 是 profile launcher、组合包层、`--patch` 覆盖层与 agent preset 用户层共用的唯一解析器,因此其中一方接受的文件所有人都接受。`PatchDocument` 在键级编辑这样的文件,同时保留作者写下的注释、空行与 `!!js` 标量;`mutatePatchFile` 在跨进程写锁下提交一次编辑,经原子替换落盘,并在报告结果前把写入的文本经解析器回读,告诉调用方每个读者此后会加载到什么。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +用 `parsePatchList`(已有文本)或 `readPatchListFile`(文件不存在时读到 `undefined`)读取一个补丁层。两者都把 `insert` 行中相对的名字(如 `./plugin.js`)锚定到文件自己的目录,并对任何不是"映射序列"的内容直接报错,因为一个完全无法施加的补丁文件就是配置错误;而目标行不存在的单条补丁仍然只是 Loader 的逐条警告。 + +通过 `mutatePatchFile` 写入。回调拿到一个 `PatchDocument`,按 Loader 寻址行的方式以行 id 编辑: + +```ts +await mutatePatchFile(file, (document) => { + document.setRowField('tool-web', 'disabled', true) // the id-targeted patch is created when absent + document.deleteRowField('tool-web', 'config') // a patch reduced to its id is removed whole + document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' }) // into the root list + document.appendInsert({ id: 'sql', name: 'dsh-sql' }, 'agents') // into the group with that id + document.removeInsert('tool-foo') // an emptied insert patch is removed whole +}, { binName: 'dsh', mode: 0o600, dirMode: 0o700 }) +``` + +`setRowField` 永远不接受 `id` 与 `insert`;`rowField` 读回一个键,`!!js` 标量以其源文本返回。`appendInsert` 拒绝文件已插入的 id;`insertedRow`/`removeInsert` 也能找到插入组内部的行。写入的值都是普通数据;别的键上的 `!!js` 标量原样不动,这正是用户层能撤回自己写的 `disabled: true` 而不惊动组合包在另一行上的 `!!js` 门的原因。 + +`mutatePatchFile` 像 `dsh-atomic-write` 一样占用 `.lock` 兄弟文件,读取文件(不存在按空处理),施加编辑,在文本有变化时以声明的权限位原子替换文件,然后返回从写入文本重新读出的补丁列表。什么都没改的编辑什么都不写。 + +----- + + +## 理解实现 + +
+实现内幕——点击展开 + +### 两个解析器,一种方言 + +读取用 `js-yaml` 配 include 的 `entryListSchema`,于是 `!!js` 标量成为 Loader 插值的表达式节点,与 include 挂载时完全一致。写入用 `yaml` 包保留注释的 `Document`:它在所修饰的标量上保留未解析的 `!!js` 标签(报告为 `TAG_RESOLVE_FAILED` 警告而非错误)并原样打印回去,因此对一个键的编辑绝不会改写另一个键的表达式。写出的文本再用读取解析器解析一遍,这就是写入器契约承诺的回读。 + +### 寻址 + +按 id 定位的补丁是 `id` 匹配且不带 `insert` 的顶层项。插入的行在每个 `insert` 列表中查找,并递归进入插入的组(`group: true` 且带 `config` 列表)。顶层项不是映射,或 `insert` 的值不是列表,都会让解析失败。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `parsePatchList`、`readPatchListFile`、`anchorInsertedPluginNames`、`PatchDocument`、`mutatePatchFile` | +| — | 不发布运行时不变量伴随件;本包不持有运行时状态,其文件契约由单元测试钉住。 | + +
+ +----- + + +## 进一步探索 + +当问题在于这种文件格式在组合中的位置时,读这些。 + +- [App boot](../../boot/app-boot/README.zh.md)——从这些文件组合组合包层、用户层与覆盖层的 profile launcher。 +- [Cordis include 插件](../../../vendor/include/README.md)——`applyEntryPatches`,每一层施加时所用的补丁语义。 +- [Agent presets](../../preset/agent-presets/README.zh.md)——以此格式书写的每预设用户层。 + +----- + + +## 模型体验 + +无,本包只读写组合补丁文件;这些文件点名的行拥有全部面向模型的注册。 + +#### KV Cache 影响 + +无;本包既不组装也不发送 provider 请求。 + +## 已知限制与延期工作 + + + + +这些限制界定写入器不会对文件做什么。它们是当前包的约束,不是任务清单。 + +- **键级而非行级合并**——`setRowField('x', 'config', value)` 替换该补丁的整个 `config` 映射;只想改一个嵌套字段的调用方先用 `rowField` 读出当前值,再把合并后的映射写回。 +- **不生成表达式**——写入器只输出普通数据;`!!js` 门是作者敲进文件的东西,从不是某个 API 调用的产物。 +- **锁孤儿由操作者处理**——写入器崩溃留下的锁文件不会被竞争者移除,竞争者等待超时后失败;`dsh-atomic-write` 记录了同样的选择。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/util/patch-file/package.json b/packages/util/patch-file/package.json new file mode 100644 index 0000000000..cf18671ca2 --- /dev/null +++ b/packages/util/patch-file/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-patch-file", + "description": "Loader patch-list files (cordis.patch.yml): the shared parser with the !!js dialect and relative-name anchoring, and a comment-preserving key-level writer with lock, atomic replace, and readback", + "version": "0.1.2-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/patch-file" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "js-yaml": "^4.2.0", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@types/js-yaml": "^4.0.9" + } +} diff --git a/packages/util/patch-file/src/index.ts b/packages/util/patch-file/src/index.ts new file mode 100644 index 0000000000..be84fcc7d7 --- /dev/null +++ b/packages/util/patch-file/src/index.ts @@ -0,0 +1,397 @@ +/** + * Loader patch-list files — the `cordis.patch.yml` layers a profile, a + * bundle, a `--patch` overlay, and an agent preset's user layer are written + * in: a top-level YAML sequence of `@deepseek-ai/cordis-plugin-include` + * `PatchOptions` (id-targeted overrides and `insert` lists) in the Loader's + * own dialect, where `!!js` marks an expression the row's fiber evaluates. + * + * One home for both directions. {@link parsePatchList} is the parser every + * reader of such a file shares, so a file the boot accepts is a file every + * other consumer accepts. {@link PatchDocument} edits one file at the key + * level while keeping its comments, blank lines, and `!!js` scalars as the + * author wrote them, and {@link mutatePatchFile} commits one edit under the + * cross-process writer lock, through an atomic replace, and reads the result + * back through the parser before reporting success. + * @module @deepseek-ai/dsh-patch-file + */ + +import { mkdir, readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import * as yaml from 'js-yaml' +import { Document, isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from 'yaml' +import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +/** + * Resolve relative plugin paths in one patch list's `insert` rows against the + * file's own directory, without changing assertion names. A row naming + * `./plugin.js` means the file beside the patch file, wherever the Loader's + * root happens to be. + * @param patches - the parsed patch list, mutated in place. + * @param file - the patch file's path, whose directory anchors the names. + * @returns the same list. + */ +export function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] { + const base = dirname(resolve(file)) + const visit = (entry: EntryOptions): void => { + if (typeof entry.name === 'string' && (entry.name.startsWith('./') || entry.name.startsWith('../'))) { + entry.name = pathToFileURL(resolve(base, entry.name)).href + } + if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit) + } + for (const patch of patches) patch.insert?.forEach(visit) + return patches +} + +/** + * Parse one loader patch list: a top-level YAML array of `PatchOptions` + * (id-targeted config overrides and `insert` lists, `!!js` expressions + * allowed). Every invalid field or value throws, because a patch file that + * cannot be applied at all is a misconfiguration; a single patch whose target + * row is absent stays a per-entry Loader warning, so one overlay shared + * across surfaces does not have to match every tree. + * @param binName - the diagnostic prefix on the thrown error. + * @param file - the source path, quoted in errors and anchoring relative names. + * @param content - the file's text. + * @param label - what to call this list in errors (`patches`, `overlay`). + * @returns the parsed patch list. + */ +export function parsePatchList( + binName: string, file: string, content: string, label: string, +): PatchOptions[] { + let parsed: unknown + try { + parsed = yaml.load(content, { schema: entryListSchema }) + } catch (error) { + throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) + } + // An empty file is an empty layer: js-yaml reads it as undefined. + if (parsed === undefined || parsed === null) return [] + if (!Array.isArray(parsed)) { + throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`) + } + parsed.forEach((entry, index) => { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new Error(`${binName}: ${label} entry ${String(index + 1)} in ${file} must be a mapping (a loader patch entry)`) + } + }) + return anchorInsertedPluginNames(parsed as PatchOptions[], file) +} + +/** + * Read and parse one patch-list file. + * @param binName - the diagnostic prefix on the thrown error. + * @param file - absolute path of the file. + * @param label - what to call this list in errors (`patches`, `overlay`). + * @returns the parsed list, or undefined when the file does not exist. + * @throws when the file exists but cannot be read or parsed. + */ +export async function readPatchListFile(binName: string, file: string, label: string): Promise { + let content: string + try { + content = await readFile(file, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined + throw new Error(`${binName}: failed to read ${label} ${file}: ${String(error)}`) + } + return parsePatchList(binName, file, content, label) +} + +/** One inserted row as the writer addresses it: the Loader's row options. */ +export type PatchRow = EntryOptions + +/** + * One patch-list file held as a comment-preserving document. + * + * Edits address rows by id the way the Loader does: an id-targeted patch is + * the top-level item whose `id` matches and that carries no `insert`; an + * inserted row is found inside any `insert` list, recursing into inserted + * groups. Values written are plain data; a `!!js` scalar the author wrote on + * another key is left exactly as it stands. + */ +export class PatchDocument { + private readonly document: Document + private readonly root: YAMLSeq + private edited = false + + private constructor(document: Document, root: YAMLSeq) { + this.document = document + this.root = root + } + + /** Whether any edit changed the document since it was parsed. */ + get dirty(): boolean { + return this.edited + } + + /** + * Remove one item from a sequence, keeping the comment block written above + * it: the comment moves to the following item, or becomes the document's + * trailing comment when the removed item was the last one. Without this a + * user's note above a row would vanish with the row it happened to precede. + */ + private splice(seq: YAMLSeq, item: YAMLMap): void { + const index = seq.items.indexOf(item) + seq.items.splice(index, 1) + this.edited = true + const comment = item.commentBefore + if (comment === null || comment === undefined) return + const next = seq.items[index] + if (isMap(next)) { + next.commentBefore = next.commentBefore === null || next.commentBefore === undefined + ? comment + : `${comment}\n${next.commentBefore}` + return + } + // Prepended: items are removed from the end one at a time, so the file's + // own order survives in the trailing comment. + this.document.comment = this.document.comment === null + ? comment + : `${comment}\n${this.document.comment}` + } + + /** + * Parse a patch-list file's text. + * @param text - the file's text; empty or comment-only text is an empty list. + * @param file - the path, quoted in errors. + * @returns the document. + * @throws when the text is not valid YAML or its root is not a sequence. + */ + static parse(text: string, file: string): PatchDocument { + // Typed as the general document: a comment-only file parses with no + // contents, and the empty sequence assigned then is not a parsed node. + const document: Document = parseDocument(text, { prettyErrors: true }) + // First line only: the parser appends a multi-line code frame. + const problems = document.errors.map(error => error.message.replace(/\n[\s\S]*$/, '')) + if (problems.length > 0) { + throw new Error(`patch-file: ${file} is not valid YAML: ${problems.join('; ')}`) + } + let root: unknown = document.contents + if (root === null) { + root = new YAMLSeq() + document.contents = root as YAMLSeq + } + if (!isSeq(root)) { + throw new Error(`patch-file: ${file} must be a top-level YAML array of loader patch entries`) + } + for (const [index, item] of root.items.entries()) { + if (!isMap(item)) { + throw new Error(`patch-file: entry ${String(index + 1)} in ${file} must be a mapping (a loader patch entry)`) + } + } + return new PatchDocument(document, root) + } + + /** The top-level patch items, each a mapping. */ + private get items(): YAMLMap[] { + return this.root.items as YAMLMap[] + } + + /** The id-targeted patch for `id`: the item with that id and no `insert`. */ + private targeted(id: string): YAMLMap | undefined { + return this.items.find(item => !item.has('insert') && item.get('id') === id) + } + + /** + * Whether an id-targeted patch for `id` exists. + * @param id - the row id the patch targets. + * @returns true when the file carries one. + */ + hasRow(id: string): boolean { + return this.targeted(id) !== undefined + } + + /** + * One key of the id-targeted patch for `id`, as plain data. + * @param id - the row id the patch targets. + * @param key - the patch key. + * @returns the value, or undefined when the patch or the key is absent. A + * `!!js` scalar reads as its source text. + */ + rowField(id: string, key: string): unknown { + const patch = this.targeted(id) + if (patch === undefined || !patch.has(key)) return undefined + return patch.get(key) + } + + /** + * Set one key on the id-targeted patch for `id`, creating the patch when the + * file has none. Other keys of the patch and every comment stay. + * @param id - the row id the patch targets. + * @param key - the patch key (`disabled`, `config`, ...); never `id`. + * @param value - plain data to write. + */ + setRowField(id: string, key: string, value: unknown): void { + if (key === 'id' || key === 'insert') { + throw new Error(`patch-file: ${key} is not a settable key of an id-targeted patch`) + } + let patch = this.targeted(id) + if (patch === undefined) { + patch = new YAMLMap() + patch.set('id', id) + this.root.add(patch) + } + patch.set(key, this.document.createNode(value)) + this.edited = true + } + + /** + * Delete one key from the id-targeted patch for `id`; a patch left with + * only its id is removed whole, so an override fully reverted leaves no + * trace. + * @param id - the row id the patch targets. + * @param key - the patch key to delete. + * @returns true when the key was present. + */ + deleteRowField(id: string, key: string): boolean { + const patch = this.targeted(id) + if (patch === undefined || !patch.has(key)) return false + patch.delete(key) + this.edited = true + const remaining = patch.items.map(pair => String(pair.key)) + if (remaining.length === 1 && remaining[0] === 'id') this.splice(this.root, patch) + return true + } + + /** Every `insert` list in the file, with the group id each targets. */ + private insertLists(): { patch: YAMLMap; into: string | undefined; rows: YAMLSeq }[] { + const found: { patch: YAMLMap; into: string | undefined; rows: YAMLSeq }[] = [] + for (const patch of this.items) { + if (!patch.has('insert')) continue + const rows = patch.get('insert', true) + if (!isSeq(rows)) { + throw new Error('patch-file: an insert patch must hold a list of rows') + } + const into = patch.get('id') + found.push({ patch, into: typeof into === 'string' ? into : undefined, rows }) + } + return found + } + + /** + * Append one row to the `insert` list targeting `into` (the root list when + * omitted), creating the patch when the file has none for that target. + * @param row - the row options to insert; plain data. + * @param into - the id of the group the row goes into, or undefined for the root. + * @throws when a row with the same id is already inserted by this file. + */ + appendInsert(row: PatchRow, into?: string): void { + if (typeof row.id === 'string' && this.insertedRow(row.id) !== undefined) { + throw new Error(`patch-file: row ${JSON.stringify(row.id)} is already inserted by this file`) + } + this.edited = true + const list = this.insertLists().find(candidate => candidate.into === into) + if (list !== undefined) { + list.rows.add(this.document.createNode(row)) + return + } + this.root.add(this.document.createNode({ + ...into === undefined ? {} : { id: into }, + insert: [row], + })) + } + + /** Walk one row list for the row with `id`, recursing into inserted groups. */ + private static findInserted(rows: YAMLSeq, id: string): { parent: YAMLSeq; row: YAMLMap } | undefined { + for (const item of rows.items) { + if (!isMap(item)) continue + if (item.get('id') === id) return { parent: rows, row: item } + if (item.get('group') !== true) continue + const nested = item.get('config', true) + if (!isSeq(nested)) continue + const found = PatchDocument.findInserted(nested, id) + if (found !== undefined) return found + } + return undefined + } + + /** + * One row this file inserts, as plain data. + * @param id - the inserted row's id. + * @returns the row options, or undefined when this file inserts no such row. + */ + insertedRow(id: string): PatchRow | undefined { + for (const list of this.insertLists()) { + const found = PatchDocument.findInserted(list.rows, id) + if (found !== undefined) return found.row.toJSON() as PatchRow + } + return undefined + } + + /** + * Remove one row this file inserts; an `insert` patch left empty is removed + * whole. + * @param id - the inserted row's id. + * @returns true when the row was present. + */ + removeInsert(id: string): boolean { + for (const list of this.insertLists()) { + const found = PatchDocument.findInserted(list.rows, id) + if (found === undefined) continue + this.splice(found.parent, found.row) + if (list.rows.items.length === 0) this.splice(this.root, list.patch) + return true + } + return false + } + + /** + * Render the document as text, comments and `!!js` scalars intact. + * @returns the YAML text, ending in one newline; an empty list renders as `[]`. + */ + toString(): string { + return this.document.toString() + } +} + +/** Filesystem options for {@link mutatePatchFile}. */ +export interface MutatePatchFileOptions { + /** The diagnostic prefix on thrown errors. */ + binName: string + /** Permission bits for the replaced file; required so the decision stays at the call site. */ + mode: number + /** Permission bits for parent directories this call creates. */ + dirMode?: number + /** Maximum time to wait for the writer lock, in milliseconds. */ + waitMs?: number +} + +/** + * Apply one edit to a patch-list file: take the cross-process writer lock, + * read the file (absent reads as empty), let `mutate` edit the document, + * replace the file atomically, and parse the written text back through + * {@link parsePatchList} so the caller learns what every reader will now + * load. An edit that changes nothing writes nothing. + * @param file - absolute path of the patch-list file. + * @param mutate - the edit, applied to the parsed document. + * @param options - diagnostics, permissions, and lock wait. + * @returns the patch list as re-read from the file after the write. + * @throws when the file cannot be parsed, the lock cannot be taken, or the + * written file does not parse — the last is reported after the write landed. + */ +export async function mutatePatchFile( + file: string, + mutate: (document: PatchDocument) => void, + options: MutatePatchFileOptions, +): Promise { + await mkdir(dirname(file), { recursive: true, ...options.dirMode === undefined ? {} : { mode: options.dirMode } }) + return await withFileLock(file, async () => { + let before: string + try { + before = await readFile(file, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + throw new Error(`${options.binName}: failed to read patches ${file}: ${String(error)}`) + } + before = '' + } + const document = PatchDocument.parse(before, file) + mutate(document) + if (!document.dirty) return parsePatchList(options.binName, file, before, 'patches') + const after = document.toString() + await writeFileAtomic(file, after, { mode: options.mode, ...options.dirMode === undefined ? {} : { dirMode: options.dirMode } }) + return parsePatchList(options.binName, file, after, 'patches') + }, options.waitMs === undefined ? undefined : { waitMs: options.waitMs }) +} diff --git a/packages/util/patch-file/tests/patch-file.spec.ts b/packages/util/patch-file/tests/patch-file.spec.ts new file mode 100644 index 0000000000..7d531bac9b --- /dev/null +++ b/packages/util/patch-file/tests/patch-file.spec.ts @@ -0,0 +1,251 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { isJsExpr } from '@deepseek-ai/cordis-plugin-loader' +import { + anchorInsertedPluginNames, + mutatePatchFile, + parsePatchList, + PatchDocument, + readPatchListFile, +} from '../src/index.ts' + +const SAMPLE = `# top comment +- id: message-feedback # trailing + disabled: true + +# insert block +- insert: + - id: tool-foo + name: dsh-tool-foo + config: {} + - id: group-a + name: cordis:group + group: true + config: + - id: nested-row + name: dsh-nested +- id: tool-bash + disabled: !!js process.platform === 'win32' + config: + a: 1 # keep me +` + +async function tempDir(): Promise { + return await mkdtemp(join(tmpdir(), 'dsh-patch-file-')) +} + +describe('PatchDocument', () => { + it('reads an empty or comment-only file as an empty list', () => { + expect(PatchDocument.parse('', 'x.yml').toString()).toBe('[]\n') + expect(PatchDocument.parse('# only a comment\n', 'x.yml').toString()).toContain('[]') + }) + + it('refuses a root that is not a sequence, a non-map item, and invalid YAML', () => { + expect(() => PatchDocument.parse('a: 1\n', 'x.yml')).toThrow('must be a top-level YAML array') + expect(() => PatchDocument.parse('- just-a-string\n', 'x.yml')).toThrow('entry 1 in x.yml must be a mapping') + expect(() => PatchDocument.parse('- id: [\n', 'x.yml')).toThrow('x.yml is not valid YAML') + }) + + it('reads and sets id-targeted patch keys while keeping comments and !!js scalars', () => { + const document = PatchDocument.parse(SAMPLE, 'x.yml') + expect(document.hasRow('message-feedback')).toBe(true) + expect(document.hasRow('tool-foo')).toBe(false) + expect(document.rowField('message-feedback', 'disabled')).toBe(true) + expect(document.rowField('message-feedback', 'config')).toBeUndefined() + expect(document.rowField('absent', 'disabled')).toBeUndefined() + // A `!!js` scalar reads as its source text; the writer never evaluates it. + expect(document.rowField('tool-bash', 'disabled')).toBe("process.platform === 'win32'") + + document.setRowField('tool-bash', 'config', { a: 1, b: 'x' }) + document.setRowField('new-row', 'disabled', true) + const text = document.toString() + expect(text).toContain('# top comment') + expect(text).toContain('- id: message-feedback # trailing') + expect(text).toContain("disabled: !!js process.platform === 'win32'") + expect(text).toContain('- id: new-row\n disabled: true') + expect(text).toMatch(/config:\n\s+a: 1\n\s+b: x/) + // The rewritten text is what the boot parser loads. + const rows = parsePatchList('spec', 'x.yml', text, 'patches') + expect(rows.find(patch => patch.id === 'tool-bash')?.config).toEqual({ a: 1, b: 'x' }) + expect(isJsExpr(rows.find(patch => patch.id === 'tool-bash')?.disabled)).toBe(true) + }) + + it('refuses to set id or insert through the key setter', () => { + const document = PatchDocument.parse(SAMPLE, 'x.yml') + expect(() => { document.setRowField('tool-bash', 'id', 'other') }).toThrow('id is not a settable key') + expect(() => { document.setRowField('tool-bash', 'insert', []) }).toThrow('insert is not a settable key') + }) + + it('deletes keys and removes a patch reduced to its id', () => { + const document = PatchDocument.parse(SAMPLE, 'x.yml') + expect(document.deleteRowField('absent', 'disabled')).toBe(false) + expect(document.deleteRowField('tool-bash', 'absent')).toBe(false) + expect(document.deleteRowField('tool-bash', 'config')).toBe(true) + expect(document.hasRow('tool-bash')).toBe(true) + expect(document.deleteRowField('message-feedback', 'disabled')).toBe(true) + expect(document.hasRow('message-feedback')).toBe(false) + const text = document.toString() + expect(text).not.toContain('message-feedback') + expect(text).toContain('# top comment') + expect(text).not.toContain('keep me') + }) + + it('appends inserted rows to the root list, to a group, and creates the patch when absent', () => { + const document = PatchDocument.parse(SAMPLE, 'x.yml') + document.appendInsert({ id: 'tool-bar', name: 'dsh-tool-bar' }) + document.appendInsert({ id: 'in-group', name: 'dsh-in-group', config: { k: 1 } }, 'agents') + document.appendInsert({ id: 'in-group-2', name: 'dsh-in-group-2' }, 'agents') + const rows = parsePatchList('spec', 'x.yml', document.toString(), 'patches') + const root = rows.find(patch => patch.insert !== undefined && patch.id === undefined) + expect(root?.insert?.map(row => row.id)).toEqual(['tool-foo', 'group-a', 'tool-bar']) + const grouped = rows.find(patch => patch.id === 'agents') + expect(grouped?.insert?.map(row => row.id)).toEqual(['in-group', 'in-group-2']) + expect(grouped?.insert?.[0]?.config).toEqual({ k: 1 }) + }) + + it('refuses to insert a row id the file already inserts', () => { + const document = PatchDocument.parse(SAMPLE, 'x.yml') + expect(() => { document.appendInsert({ id: 'nested-row', name: 'x' }) }).toThrow('"nested-row" is already inserted') + }) + + it('finds and removes inserted rows, including inside inserted groups', () => { + const document = PatchDocument.parse(SAMPLE, 'x.yml') + expect(document.insertedRow('tool-foo')).toEqual({ id: 'tool-foo', name: 'dsh-tool-foo', config: {} }) + expect(document.insertedRow('nested-row')).toEqual({ id: 'nested-row', name: 'dsh-nested' }) + expect(document.insertedRow('absent')).toBeUndefined() + expect(document.removeInsert('absent')).toBe(false) + expect(document.removeInsert('nested-row')).toBe(true) + expect(document.insertedRow('nested-row')).toBeUndefined() + expect(document.removeInsert('tool-foo')).toBe(true) + expect(document.removeInsert('group-a')).toBe(true) + // The insert patch emptied by the removals is gone with its comment block. + expect(document.toString()).not.toContain('insert:') + expect(document.toString()).toContain('- id: tool-bash') + }) + + it('skips non-map items and non-group rows while walking insert lists', () => { + const document = PatchDocument.parse( + '- insert:\n - plain\n - id: leaf\n name: x\n group: true\n config: notalist\n', 'x.yml', + ) + expect(document.insertedRow('leaf')).toEqual({ id: 'leaf', name: 'x', group: true, config: 'notalist' }) + expect(document.insertedRow('missing')).toBeUndefined() + }) + + it('refuses an insert patch whose list is not a sequence', () => { + const document = PatchDocument.parse('- insert: 1\n', 'x.yml') + expect(() => document.insertedRow('x')).toThrow('insert patch must hold a list of rows') + }) +}) + +describe('parsePatchList', () => { + it('reads empty text as an empty layer and anchors relative names to the file', () => { + expect(parsePatchList('spec', '/tmp/x.yml', '', 'patches')).toEqual([]) + const rows = parsePatchList('spec', '/tmp/dir/x.yml', [ + '- insert:', + ' - id: rel', + ' name: ./plugin.js', + ' - id: bare', + ' name: dsh-bare', + ' - id: g', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: up', + ' name: ../up.js', + '- id: other', + ' disabled: true', + ].join('\n'), 'overlay') + const insert = rows[0]?.insert ?? [] + expect(insert[0]?.name).toBe(pathToFileURL(resolve('/tmp/dir', './plugin.js')).href) + expect(insert[1]?.name).toBe('dsh-bare') + expect((insert[2]?.config as { name: string }[])[0]?.name).toBe(pathToFileURL(resolve('/tmp/dir', '../up.js')).href) + expect(rows[1]).toEqual({ id: 'other', disabled: true }) + }) + + it('fails loud on invalid YAML, a non-array root, and a non-map entry', () => { + expect(() => parsePatchList('spec', 'x.yml', '- id: [\n', 'overlay')).toThrow('spec: failed to parse overlay x.yml') + expect(() => parsePatchList('spec', 'x.yml', 'a: 1\n', 'patches')).toThrow('patches x.yml must be a top-level YAML array') + expect(() => parsePatchList('spec', 'x.yml', '- 1\n', 'patches')).toThrow('patches entry 1 in x.yml must be a mapping') + }) + + it('anchors names in place and returns the same list', () => { + const patches = [{ insert: [{ id: 'a', name: './a.js' }] }] + expect(anchorInsertedPluginNames(patches, '/tmp/dir/x.yml')).toBe(patches) + expect(patches[0]?.insert[0]?.name).toBe(pathToFileURL('/tmp/dir/a.js').href) + }) +}) + +describe('readPatchListFile', () => { + it('reads undefined for an absent file, the list for a present one, and throws for an unreadable one', async () => { + const dir = await tempDir() + expect(await readPatchListFile('spec', join(dir, 'absent.yml'), 'patches')).toBeUndefined() + await writeFile(join(dir, 'x.yml'), '- id: a\n disabled: true\n') + expect(await readPatchListFile('spec', join(dir, 'x.yml'), 'patches')).toEqual([{ id: 'a', disabled: true }]) + await expect(readPatchListFile('spec', dir, 'patches')).rejects.toThrow('spec: failed to read patches') + }) +}) + +describe('mutatePatchFile', () => { + it('creates the file under the lock, replaces it atomically, and reads it back', async () => { + const dir = await tempDir() + const file = join(dir, 'nested', 'cordis.patch.yml') + const rows = await mutatePatchFile(file, (document) => { + document.setRowField('tool-web', 'disabled', true) + document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' }) + }, { binName: 'spec', mode: 0o600, dirMode: 0o700 }) + expect(rows).toEqual([{ id: 'tool-web', disabled: true }, { insert: [{ id: 'tool-foo', name: 'dsh-tool-foo' }] }]) + expect(await readFile(file, 'utf8')).toBe('- id: tool-web\n disabled: true\n- insert:\n - id: tool-foo\n name: dsh-tool-foo\n') + expect(existsSync(`${file}.lock`)).toBe(false) + if (process.platform !== 'win32') expect((await stat(file)).mode & 0o777).toBe(0o600) + + // A second edit keeps the author's comment and reverts only its own key. + await writeFile(file, `# mine\n${await readFile(file, 'utf8')}`) + const reverted = await mutatePatchFile(file, (document) => { + document.deleteRowField('tool-web', 'disabled') + }, { binName: 'spec', mode: 0o600, waitMs: 500 }) + expect(reverted).toEqual([{ insert: [{ id: 'tool-foo', name: 'dsh-tool-foo' }] }]) + expect(await readFile(file, 'utf8')).toContain('# mine') + }) + + it('writes nothing when the edit changes nothing', async () => { + const dir = await tempDir() + const file = join(dir, 'cordis.patch.yml') + expect(await mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 })).toEqual([]) + expect(existsSync(file)).toBe(false) + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('reports a file that exists but cannot be read', async () => { + const dir = await tempDir() + const file = join(dir, 'cordis.patch.yml') + await writeFile(file, '[]\n', { mode: 0o000 }) + await expect(mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 })) + .rejects.toThrow('spec: failed to read patches') + expect(existsSync(`${file}.lock`)).toBe(false) + }) + + it('keeps the comment above a removed row', async () => { + const dir = await tempDir() + const file = join(dir, 'cordis.patch.yml') + await writeFile(file, '# first\n- id: a\n disabled: true\n# second\n- id: b\n disabled: true\n# third\n- id: c\n disabled: true\n') + await mutatePatchFile(file, (document) => { document.deleteRowField('a', 'disabled') }, { binName: 'spec', mode: 0o600 }) + expect(await readFile(file, 'utf8')).toBe('# first\n# second\n- id: b\n disabled: true\n# third\n- id: c\n disabled: true\n') + // Removing from the end twice keeps the file's own comment order. + await mutatePatchFile(file, (document) => { + document.deleteRowField('c', 'disabled') + document.deleteRowField('b', 'disabled') + }, { binName: 'spec', mode: 0o600 }) + expect(await readFile(file, 'utf8')).toBe('[]\n\n# first\n# second\n# third\n') + }) + + it('reports a file that does not parse', async () => { + const dir = await tempDir() + const file = join(dir, 'cordis.patch.yml') + await writeFile(file, 'a: 1\n') + await expect(mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 })) + .rejects.toThrow('must be a top-level YAML array') + }) +}) diff --git a/packages/util/patch-file/tsconfig.json b/packages/util/patch-file/tsconfig.json new file mode 100644 index 0000000000..98e2cac0fc --- /dev/null +++ b/packages/util/patch-file/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../atomic-write" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb5d543dc1..462c6cb81a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,9 @@ importers: '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph + '@deepseek-ai/dsh-global-tool-mask': + specifier: workspace:^ + version: link:../../packages/preset/global-tool-mask '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -723,6 +726,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory + '@deepseek-ai/dsh-host-plugin-manager': + specifier: workspace:^ + version: link:../../host/plugin-manager '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -961,6 +967,9 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write + '@deepseek-ai/dsh-patch-file': + specifier: workspace:^ + version: link:../../util/patch-file js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -1624,6 +1633,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory + '@deepseek-ai/dsh-host-plugin-manager': + specifier: workspace:^ + version: link:../../host/plugin-manager '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -5802,6 +5814,43 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/host/plugin-manager: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-group': + specifier: workspace:^ + version: link:../../../vendor/group + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot + '@deepseek-ai/dsh-patch-file': + specifier: workspace:^ + version: link:../../util/patch-file + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': @@ -6583,6 +6632,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-patch-file': + specifier: workspace:^ + version: link:../../util/patch-file '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -6624,6 +6676,25 @@ importers: specifier: workspace:^ version: link:../../core/system-prompt + packages/preset/global-tool-mask: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/runtime-diagnostics/invariants: dependencies: '@deepseek-ai/schemastery': @@ -9349,6 +9420,31 @@ importers: specifier: workspace:^ version: link:../../../vendor/cordis + packages/util/patch-file: + dependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../atomic-write + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + packages/util/time: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 077bd779e9..47fc4414c9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -58,6 +58,7 @@ export const SERVICE_PAGE: Record = { agentDefaultModel: 'core.md', agentPresets: 'core.md', profileRuntime: 'core.md', + pluginManager: 'core.md', agents: 'core.md', approval: 'approval.md', attachments: 'attachment.md', @@ -188,6 +189,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'agent': 'core.md', 'agent-loop': 'core.md', 'agent-preset': 'core.md', + 'plugins': 'core.md', 'api-session': 'session.md', 'approval': 'approval.md', 'commands': 'commands.md', @@ -668,6 +670,14 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md', AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', RowOrigin: 'row provenance record of the profile runtime is owned by packages/boot/app-boot/README.md', + PluginChangeReason: 'plugin manager change reasons are owned by packages/host/plugin-manager/README.md', + PluginInstallLogChunk: 'plugin manager install log chunks are owned by packages/host/plugin-manager/README.md', + PluginPackageView: 'plugin manager package views are owned by packages/host/plugin-manager/README.md', + PluginInstallResult: 'plugin manager install results are owned by packages/host/plugin-manager/README.md', + PluginEnableResult: 'plugin manager enable results are owned by packages/host/plugin-manager/README.md', + PluginRowTarget: 'plugin manager row targets are owned by packages/host/plugin-manager/README.md', + PluginRowAddition: 'plugin manager row additions are owned by packages/host/plugin-manager/README.md', + PluginDependents: 'plugin manager dependents are owned by packages/host/plugin-manager/README.md', AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md', AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md', AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c78688db82..050c606c1b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -377,12 +377,19 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.', }, + { + key: 'pluginManager', + pkg: 'host-plugin-manager', + title: 'Plugin management over the booted profile', + mode: 'core', + note: 'Installs, enables, disables, retries, and removes bundles through pnpm and the profile runtime, edits rows in the profile\'s or a preset\'s user layer, and folds manifest, probe, and tree facts into one view per package for the plugins Remote.', + }, { key: 'profileRuntime', pkg: 'app-boot', title: 'Booted profile facts and recomposition', mode: 'core', - consumers: ['host-plugin-inventory'], + consumers: ['host-plugin-inventory', 'host-plugin-manager'], note: 'Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row\'s inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share.', }, { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 565693d35f..0147180bd5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -55,8 +55,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/code-runtime/code-runtime-worker-thread': { kind: 'indirect', reason: 'The worker backend delegates model rendering to PTC mode in dsh-tools.' }, 'packages/experimental/code-runtime-python': { kind: 'indirect', reason: 'Explicit source-checkout compositions delegate model rendering to PTC mode in dsh-tools.' }, 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, + 'packages/preset/global-tool-mask': { kind: 'indirect', reason: 'The row only calls tools.restrict(); the registry owns the tool list the model sees.' }, 'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' }, 'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' }, + 'packages/util/patch-file': { kind: 'none', reason: 'Composition patch-file parser and writer; the rows those files name own every model-facing registration.' }, 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, 'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' }, @@ -126,6 +128,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/webhook/webhook-github': { kind: 'indirect', reason: 'The adapter delegates model-visible text to matching rules and dsh-webhook.' }, 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' }, 'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' }, + 'packages/host/plugin-manager': { kind: 'none', reason: 'Host-side plugin management over the profile; the rows it composes own every model-facing registration.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' }, 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f7c66624a..a0380ba3a8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -148,6 +148,8 @@ "@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"], "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], + "@deepseek-ai/dsh-host-plugin-manager": ["./packages/host/plugin-manager/src"], + "@deepseek-ai/dsh-host-plugin-manager/types": ["./packages/host/plugin-manager/src/types.ts"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], @@ -276,6 +278,7 @@ "@deepseek-ai/dsh-fs-local": ["./packages/fs/fs-local/src"], "@deepseek-ai/dsh-fs-observation-policy": ["./packages/fs/fs-observation-policy/src"], "@deepseek-ai/dsh-fs-sandbox": ["./packages/fs/fs-sandbox/src"], + "@deepseek-ai/dsh-global-tool-mask": ["./packages/preset/global-tool-mask/src"], "@deepseek-ai/dsh-goal": ["./packages/goal/goal/src"], "@deepseek-ai/dsh-goal/invariant": ["./packages/goal/goal/src/invariant.ts"], "@deepseek-ai/dsh-goal-round-driver": ["./packages/goal/goal-round-driver/src"], @@ -306,6 +309,7 @@ "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], + "@deepseek-ai/dsh-patch-file": ["./packages/util/patch-file/src"], "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"], "@deepseek-ai/dsh-permission-presets/invariant": ["./packages/interaction/permission-presets/src/invariant.ts"], "@deepseek-ai/dsh-persona": ["./packages/preset/persona/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 8ac855ae76..1a1c797a71 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -141,6 +141,7 @@ { "path": "./packages/util/workspace-path" }, { "path": "./packages/util/output-retention" }, { "path": "./packages/util/atomic-write" }, + { "path": "./packages/util/patch-file" }, { "path": "./packages/attachment/attachment" }, { "path": "./packages/attachment/attachment-local" }, { "path": "./packages/llm/llm" }, @@ -314,6 +315,7 @@ { "path": "./packages/plan/plan-mode" }, { "path": "./packages/preset/agent-presets" }, { "path": "./packages/preset/persona" }, + { "path": "./packages/preset/global-tool-mask" }, { "path": "./packages/guard/repeat-tool-reminder" }, { "path": "./packages/extensions/cordis-host-runner" }, { "path": "./packages/extensions/tool-cordis" }, @@ -327,6 +329,7 @@ { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, { "path": "./packages/host/plugin-inventory" }, + { "path": "./packages/host/plugin-manager" }, { "path": "./packages/llm/plugin-package-inventory-deepseek" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, From 80ea5fcdf5609dae605eff3e612ffd43661119e1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 03:03:43 +0800 Subject: [PATCH 003/147] feat(settings): resolve namespaces per named scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A settings namespace is now a kind; each registration is an instance filed under the caller's nearest named scope (dsh-scope gains createScope(..., { id }) and scopeIdOf). Instances resolve defaults → composition base → global user section → scopes... Global writes fan out to every instance gated on its own resolved value; scoped writes commit one instance. Revisions are per section so a scope nothing has registered yet can still be written when the kind exists. describe(), the controller verbs, and both events take an optional trailing scope. Presets name their standing scopes preset/; skill-filesystem is the first consumer, resolving customSkillDirs per scope with live updates. --- ...ngs-namespaces-resolve-per-scope.i18n.yaml | 6 + ...4-settings-namespaces-resolve-per-scope.md | 37 + ...ettings-namespaces-resolve-per-scope.zh.md | 37 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 +- docs/module-graph.zh.md | 9 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 163 +++-- docs/subsystems/settings.zh.md | 163 +++-- .../api/settings-controller/README.i18n.yaml | 4 +- packages/api/settings-controller/README.md | 2 +- packages/api/settings-controller/README.zh.md | 2 +- packages/api/settings-controller/package.json | 2 + packages/api/settings-controller/src/index.ts | 59 +- .../tests/settings-controller.host.spec.ts | 44 ++ .../api/settings-controller/tsconfig.json | 3 + .../client/connection/src/client/fixture.ts | 2 + .../permission-presets-row.client.spec.tsx | 1 + .../tests/settings-store.client.spec.ts | 1 + .../tests/components.client.spec.tsx | 6 + .../tests/onboarding-dialog.client.spec.tsx | 1 + .../tests/provider-form.client.spec.tsx | 1 + .../tests/stores.client.spec.ts | 2 +- .../tests/settings-mirror.client.spec.ts | 2 +- .../tests/settings-scope.client.spec.ts | 1 + packages/core/scope/README.i18n.yaml | 4 +- packages/core/scope/README.md | 4 +- packages/core/scope/README.zh.md | 4 +- packages/core/scope/src/index.ts | 42 ++ packages/core/scope/tests/scope.spec.ts | 27 + .../extensions/tool-cordis/src/api-catalog.ts | 91 +-- packages/preset/agent-presets/src/index.ts | 17 +- .../agent-presets/tests/overlay.spec.ts | 14 + .../settings/settings-file/README.i18n.yaml | 4 +- packages/settings/settings-file/README.md | 2 +- packages/settings/settings-file/README.zh.md | 2 +- packages/settings/settings-file/src/index.ts | 56 +- .../settings-file/tests/scopes.spec.ts | 106 +++ packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 21 +- packages/settings/settings/README.zh.md | 21 +- packages/settings/settings/package.json | 2 + packages/settings/settings/src/index.ts | 631 +++++++++++++----- packages/settings/settings/src/invariant.ts | 6 +- packages/settings/settings/src/types.ts | 40 +- packages/settings/settings/tests/memory.ts | 17 +- .../settings/settings/tests/scopes.spec.ts | 359 ++++++++++ packages/settings/settings/tsconfig.json | 3 + .../skill/skill-filesystem/README.i18n.yaml | 4 +- packages/skill/skill-filesystem/README.md | 4 +- packages/skill/skill-filesystem/README.zh.md | 4 +- packages/skill/skill-filesystem/package.json | 7 + packages/skill/skill-filesystem/src/index.ts | 58 +- .../tests/skill-filesystem.spec.ts | 29 + packages/skill/skill-filesystem/tsconfig.json | 3 +- pnpm-lock.yaml | 47 +- scripts/gen-cordis-catalog.ts | 1 + 63 files changed, 1784 insertions(+), 435 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md create mode 100644 packages/settings/settings-file/tests/scopes.spec.ts create mode 100644 packages/settings/settings/tests/scopes.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml new file mode 100644 index 0000000000..2234871501 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-04-settings-namespaces-resolve-per-scope.md +2026-09-04-settings-namespaces-resolve-per-scope.md: 8275a2b2289a3bb6e31d4fc827585c742de19619 +2026-09-04-settings-namespaces-resolve-per-scope.zh.md: 04d5b2f179d4e84b744cc89a6735ceb01febb541 diff --git a/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md new file mode 100644 index 0000000000..8275a2b228 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md @@ -0,0 +1,37 @@ +# Agent Note: A settings namespace resolves per scope + +Status: implemented + +English | [中文](2026-09-04-settings-namespaces-resolve-per-scope.zh.md) + +## Problem + +The settings seam registered a namespace once per process. That fit host rows, which exist once, and broke for agent presets, which mount the same plugin under several standing scopes: the second preset's `ctx.settings.register` threw "already registered" inside a `ctx.inject` continuation, the throw vanished into a nested fiber, and the second preset read the first's value. A person who wanted `skill-filesystem` to scan one extra root for the `research` preset only had no place to say so — the document had one section per namespace, and the plugin manager's per-preset configuration story needed one. + +## Decision + +**`dsh-scope` names scopes.** `createScope(ctx, key, { id })` records a stable name on the key, and `scopeIdOf(ctx)` answers the nearest named scope along a context's parent chain, so an agent's context resolves to the preset it joined. `dsh-agent-presets` names its standing scopes `preset/`. Settings depends on `dsh-scope` alone and never on presets. + +**A namespace is a kind; a registration is an instance under a scope.** `register` reads the caller's scope through `scopeIdOf(this.ctx)` — the traceable service proxy binds `this.ctx` to the caller — and files the instance under it, the global scope being the absence of a name. Every registrant of a kind must carry the same schema envelope (`schema.toJSON()` compared with the seam's equality); a different one fails loud, as does a second instance under one scope. The first registrant's `validate` and `applies` are the kind's. + +**Four layers.** An instance resolves schema defaults, its own composition `base`, the document's global section, and its scope's section (`scopes..`) in that order, with the existing field-level merge. A global write re-resolves every instance of the kind, each gated on its own resolved value, so a scope that overrides the changed field is not disturbed; a scoped write commits that instance alone. Revisions are per section, kept off the registrations so a section written for a scope nothing has registered yet — a preset no session composed — is versioned like any other; such a write is accepted when the kind exists and is judged by the shared schema. `scopes` is a reserved namespace. + +**Describe per scope.** `describe()` answers one descriptor per kind under the global scope; `describe({ scope })` one per kind under a named scope, with `registered`, the scope's own `user` section, and `inherited` — the value without that section — so a surface can mark a field as overridden, inherited, or default. The controller's `describe(scope?)` and its three write verbs take the same optional trailing `scope`, and both events carry the scope as a trailing argument that is absent for the global instance, which keeps every existing listener and the forwarded-event carrier unchanged. + +**`skill-filesystem` is the first consumer.** Its `customSkillDirs` resolves through `installSection` with the composition value as base; a change replaces the provider's roots and invalidates the catalog, so a person adds a root for one preset from the settings document while the process runs. + +## Alternatives considered + +**A per-preset settings document.** Rejected: the document is one file with one provider; a second file per preset would need its own watcher, lock, and reload path for the same format. + +**Keying instances by the preset id inside the settings seam.** Rejected: settings would then know presets; a named scope is the general form, and a future named scope — a workspace, a team — costs nothing. + +**One registration per kind with the scope passed on every read.** Rejected: a plugin reads its handle without knowing it runs in a preset; the instance's context already says where it is. + +## Consequences + +Two presets mounting one plugin keep separate values, and the silent collision is gone. A settings surface can offer "all presets" and "this preset" over one namespace. The global `describe()` still answers one row per kind, so the existing settings page renders unchanged while the plugin manager's page (next PR) reads per scope. A kind's `validate` judges every instance, and a scope-only instance appears in the global view as `registered: false`. + +## Testing + +`packages/settings/settings/tests/scopes.spec.ts` pins registration under a named and an inherited scope, kind-level schema agreement, duplicate and reserved refusals, scoped resolution, global-write fan-out with deep-equal gating, scoped writes through the handle and the provider, per-section revisions and conflicts, writes to an unregistered scope, external scope edits, malformed scope sections, and the scoped and global describers with redaction. `packages/settings/settings-file/tests/scopes.spec.ts` pins the `scopes..` layout in YAML and JSON with comments intact. `packages/api/settings-controller/tests/settings-controller.host.spec.ts` pins the scoped Remote reads and writes; `packages/core/scope/tests/scope.spec.ts` the named-scope resolution; `packages/preset/agent-presets/tests/overlay.spec.ts` the preset scope name; `packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts` the live root change through settings. diff --git a/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md new file mode 100644 index 0000000000..04d5b2f179 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md @@ -0,0 +1,37 @@ +# Agent Note:settings 命名空间按 scope 解析 + +Status: implemented + +[English](2026-09-04-settings-namespaces-resolve-per-scope.md) | 中文 + +## 问题 + +settings seam 每个进程只允许注册一次命名空间。这对只存在一次的宿主行是对的,对 agent preset 却是坏的:同一个插件挂在多个常驻 scope 下,第二个 preset 的 `ctx.settings.register` 在 `ctx.inject` 延续里抛出"already registered",这个 throw 消失在嵌套 fiber 中,第二个 preset 读到的是第一个的值。想让 `skill-filesystem` 只为 `research` preset 多扫一个根目录的人无处可说——文档每个命名空间只有一段,而插件管理器的按 preset 配置需要这样一段。 + +## 决定 + +**`dsh-scope` 给 scope 起名。** `createScope(ctx, key, { id })` 在 key 上记录一个稳定的名字,`scopeIdOf(ctx)` 沿上下文的父链回答最近的有名 scope,于是 agent 的上下文解析到它加入的 preset。`dsh-agent-presets` 把常驻 scope 命名为 `preset/`。settings 只依赖 `dsh-scope`,从不依赖 preset。 + +**命名空间是一种 kind;注册是某个 scope 下的一个 instance。** `register` 经 `scopeIdOf(this.ctx)` 读取调用者的 scope——可追踪的服务代理把 `this.ctx` 绑到调用者——并把 instance 归档在其下,全局 scope 就是没有名字。同一 kind 的每个注册者必须带相同的 schema 信封(`schema.toJSON()` 用 seam 自己的相等谓词比较);不同的信封大声失败,同一 scope 下的第二个 instance 也是。第一个注册者的 `validate` 与 `applies` 属于 kind。 + +**四层。** 一个 instance 按顺序解析 schema 默认值、自己的组合 `base`、文档的全局段、以及其 scope 的段(`scopes..`),沿用既有的字段级合并。全局写入重新解析该 kind 的每个 instance,各自按自身解析值门控,因此覆盖了被改字段的 scope 不会被打扰;scoped 写入只提交那个 instance。revision 按段记录,与注册分离,于是为尚无注册的 scope——还没有会话组合过的 preset——写入的段与其它段同样被版本化;只要 kind 存在,这样的写入就被接受并由共享 schema 判定。`scopes` 是保留的命名空间。 + +**按 scope 描述。** `describe()` 在全局 scope 下每个 kind 回答一条描述符;`describe({ scope })` 在某个具名 scope 下每个 kind 回答一条,附带 `registered`、该 scope 自己的 `user` 段,以及 `inherited`——没有该段时的值——让界面能把字段标为已覆盖、继承或默认。controller 的 `describe(scope?)` 与三个写入动词接受同样的可选尾随 `scope`,两个事件都把 scope 作为尾随参数携带、全局 instance 时缺席,这让每个既有监听器与转发事件载体保持不变。 + +**`skill-filesystem` 是第一个消费方。** 它的 `customSkillDirs` 以组合值为 base 经 `installSection` 解析;变化会替换 provider 的根目录并让目录失效,于是一个人可以在进程运行中从 settings 文档为某个 preset 添加一个根目录。 + +## 考虑过的替代方案 + +**每个 preset 一份 settings 文档。** 否决:文档是一个文件配一个 provider;每个 preset 再来一个文件,就要为同一格式再养一套 watcher、锁与重载路径。 + +**在 settings seam 内以 preset id 作为 instance 的键。** 否决:settings 就此认识了 preset;具名 scope 是通用形式,将来的具名 scope——工作区、团队——不花任何代价。 + +**每个 kind 只注册一次,每次读取传入 scope。** 否决:插件读取自己的句柄时并不知道自己跑在 preset 里;instance 的上下文已经说明它在哪。 + +## 后果 + +挂同一插件的两个 preset 各自保有值,静默碰撞消失。settings 界面可以在一个命名空间上提供"所有 preset"与"本 preset"。全局 `describe()` 仍然每个 kind 回答一行,因此既有设置页原样渲染,而插件管理器的页面(下一 PR)按 scope 读取。kind 的 `validate` 判定每个 instance,仅在 scope 下存在的 instance 在全局视图中呈现为 `registered: false`。 + +## 测试 + +`packages/settings/settings/tests/scopes.spec.ts` 钉住具名与继承 scope 下的注册、kind 级 schema 一致性、重复与保留名拒绝、scoped 解析、带 deep-equal 门控的全局写入扇出、经句柄与 provider 的 scoped 写入、按段的 revision 与冲突、对未注册 scope 的写入、外部 scope 编辑、畸形 scope 段,以及带脱敏的 scoped 与全局描述。`packages/settings/settings-file/tests/scopes.spec.ts` 钉住 YAML 与 JSON 中 `scopes..` 的布局且注释完好。`packages/api/settings-controller/tests/settings-controller.host.spec.ts` 钉住 scoped 的 Remote 读写;`packages/core/scope/tests/scope.spec.ts` 钉住具名 scope 解析;`packages/preset/agent-presets/tests/overlay.spec.ts` 钉住 preset 的 scope 名;`packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts` 钉住经 settings 的根目录在线变更。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 45d54bd0f4..829adda101 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 17a9aafed2def073be11f738c1b02e9cde6aaf32 -config-catalog.zh.md: cb531c531d6fb80227af497a0168ca638c17faa8 +config-catalog.md: 1bc6497abbf1a7abf7b9aed26e512c88702ba1c8 +config-catalog.zh.md: ada410954aa2e30cf73dff1bf5dd96b9c7b27072 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 17a9aafed2..1bc6497abb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2156,7 +2156,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts) +Source: [`packages/skill/skill-filesystem/src/index.ts:68`](../packages/skill/skill-filesystem/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cb531c531d..ada410954a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2158,7 +2158,7 @@ export interface Config { } ``` -来源:[`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts) +来源:[`packages/skill/skill-filesystem/src/index.ts:68`](../packages/skill/skill-filesystem/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 3d41ee4c6b..8ac02f62dc 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 47aa3f6588c61b5843f1fd8c871192adc09efe28 -event-producer-consumer.zh.md: f83a5ea9f68276c3fa515e1bcb16287f7cddca91 +event-producer-consumer.md: 09c2dddca63ef98381b4f828cd9d54b00be2b54f +event-producer-consumer.zh.md: a945d7f7ccc87beda0f2459f4e92435aceff98e1 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 47aa3f6588..09c2dddca6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -52,8 +52,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:137`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:123`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f83a5ea9f6..a945d7f7cc 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -54,8 +54,8 @@ | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:137`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:123`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 9509b44eb7..1c92f865b4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 18a54edf93d052e87a7e0d5f7430cebc902f5c3a -module-graph.zh.md: ff41b5e11862b75eb075d3752b59c7eef6909e07 +module-graph.md: a5106b0afc58a85a322247cfa41f696a561efdb6 +module-graph.zh.md: c740d26586ca371c07d97e88e4a2bb3cd25adaad diff --git a/docs/module-graph.md b/docs/module-graph.md index 18a54edf93..a5106b0afc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -445,6 +445,7 @@ flowchart TD pkg_session_projection --> pkg_session pkg_settings --> pkg_brand pkg_settings --> pkg_invariants + pkg_settings --> pkg_scope pkg_settings --> pkg_session pkg_session_snapshot --> pkg_http_proxy pkg_session_snapshot --> pkg_session @@ -535,6 +536,7 @@ flowchart TD pkg_fs_observation_policy --> pkg_fs pkg_skill_filesystem --> pkg_fs pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_settings pkg_skill_filesystem --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials @@ -908,6 +910,7 @@ flowchart TD pkg_api_settings_controller --> pkg_agent_presets pkg_api_settings_controller --> pkg_credentials pkg_api_settings_controller --> pkg_native_command + pkg_api_settings_controller --> pkg_scope pkg_api_settings_controller --> pkg_session pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol @@ -1287,7 +1290,7 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1309,7 +1312,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | -| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | +| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | @@ -1383,7 +1386,7 @@ flowchart TD | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ff41b5e118..c740d26586 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -447,6 +447,7 @@ flowchart TD pkg_session_projection --> pkg_session pkg_settings --> pkg_brand pkg_settings --> pkg_invariants + pkg_settings --> pkg_scope pkg_settings --> pkg_session pkg_session_snapshot --> pkg_http_proxy pkg_session_snapshot --> pkg_session @@ -537,6 +538,7 @@ flowchart TD pkg_fs_observation_policy --> pkg_fs pkg_skill_filesystem --> pkg_fs pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_settings pkg_skill_filesystem --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials @@ -910,6 +912,7 @@ flowchart TD pkg_api_settings_controller --> pkg_agent_presets pkg_api_settings_controller --> pkg_credentials pkg_api_settings_controller --> pkg_native_command + pkg_api_settings_controller --> pkg_scope pkg_api_settings_controller --> pkg_session pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol @@ -1289,7 +1292,7 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1311,7 +1314,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | -| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | +| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | @@ -1385,7 +1388,7 @@ flowchart TD | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 94a8cc437f..f92b29cad5 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -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 docs/subsystems/settings.md -settings.md: d8e3cbc46eb697315d4938b696e921a0c2e11828 -settings.zh.md: 772fd7832ff80cab9444c16c9a1dc7ae1875d51e +settings.md: ef06544b500ec21234ce3922d9d7b7a30c3ecc2e +settings.zh.md: b5537332933043df60a62b4ebd8c39af985ebaad diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index d8e3cbc46e..ef06544b50 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'> ## Registration -Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer, the owner's effect timing, and an optional check for what the schema cannot express. +Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer, the owner's effect timing, and an optional check for what the schema cannot express. A namespace is one kind of setting; each registration is an instance of it under the caller's nearest named `dsh-scope` scope (an agent preset's `preset/`, or the global scope), and every registrant of a kind shares its schema. An instance resolves schema defaults, its own `base`, the document's global section, and its scope's own section, in that order. ```ts type-equiv /** Registration options beyond the namespace schema. */ @@ -43,6 +43,9 @@ interface SettingsRegisterOptions { * registration there is no last good value yet, so a stored section that * already fails rejects the registration itself — again exactly as a schema * failure does. + * + * The check belongs to the namespace kind: the first registrant's check + * judges every instance, because every instance is the same plugin. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void @@ -65,7 +68,7 @@ The scope is the owner-facing handle. `update` merges a sparse patch over the us ```ts type-equiv /** Owner-facing handle for one registered namespace. */ interface SettingsScope { - /** Current resolved value: schema defaults, then `base`, then the user layer. */ + /** Current resolved value: schema defaults, then `base`, then the user layers. */ get(): T /** * Observe committed changes to this namespace's resolved value. Invocations @@ -78,14 +81,15 @@ interface SettingsScope { */ watch(callback: (next: T, prev: T) => void | Promise): () => void /** - * Merge a partial patch into this namespace's user layer and persist it. + * Merge a partial patch into this registration's user section — the scope's + * own section for a scoped registration — and persist it. * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** - * Replace this namespace's user section wholesale; absent keys re-inherit - * the composition `base` and schema defaults (`replace({})` resets all). + * Replace this registration's user section wholesale; absent keys re-inherit + * the layers below (`replace({})` resets the section). * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ @@ -100,8 +104,18 @@ interface SettingsScope { ```ts type-equiv /** One registered namespace as surfaced to configuration UIs. */ interface SettingsDescriptor { + // TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the + // public API, provider contract, implementations, tests, and consumers. /** The registered namespace. */ ns: SettingsNamespace + /** The named scope the descriptor resolves under; absent for the global scope. */ + scope?: SettingsScopeId + /** + * Whether an owner registered the namespace under this scope. False for a + * scope described from the kind alone — a preset no session composed yet — + * whose value then carries no composition `base`. + */ + registered: boolean /** Serialized schemastery schema (`schema.toJSON()`). */ schema: unknown /** Current resolved value. */ @@ -116,8 +130,15 @@ interface SettingsDescriptor { /** * Raw user section from the stored document (detached), when one exists and * is well-formed; a field's presence here is what marks it user-overridden. + * For a scoped descriptor this is the scope's own section, not the global one. */ user?: unknown + /** + * For a scoped descriptor: the value the scope resolves without its own + * user section — defaults, base, and the global section — so a surface can + * tell a field the scope overrides from one it inherits. + */ + inherited?: unknown /** Owner's declared effect timing. */ applies: SettingsApplies /** Schema-declared secret positions; present only under `redactSecrets`. */ @@ -149,6 +170,12 @@ interface SettingsDescribeOptions { * the verbatim default exists for same-process configuration UIs only. */ redactSecrets?: boolean + /** + * Describe every namespace kind under this named scope instead of the + * global scope. A kind with no registration under the scope is described + * from the kind alone, `registered: false`. + */ + scope?: string } ``` @@ -191,13 +218,20 @@ prepareDocument(): Promise /** * Register a namespace schema and receive its owner scope. The registration * is an effect on the calling plugin's fiber: disposing that fiber removes - * the namespace and its observers. An invalid stored section fails the + * the instance and its observers. An invalid stored section fails the * registration itself — the earliest point where the schema can judge it. - * @param ns - unique namespace; duplicate registration fails loud. + * + * The instance registers under the caller's nearest named scope: a plugin + * mounted inside an agent preset resolves that preset's section over the + * global one, and two presets mounting the same plugin hold two instances + * of one kind. A second registrant of a namespace must carry the same + * schema envelope; a different one is a different setting under a taken + * name and fails loud. + * @param ns - the namespace; a second registration under the same scope fails loud. * @param schema - schemastery schema resolving this namespace's value. * @param options - composition `base` layer and effect timing. * @returns the owner scope for reads, observation, and updates. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved. */ register( ns: Namespace & SettingsNamespaceInput, schema: z, options?: SettingsRegisterOptions, ): SettingsScope @@ -215,63 +249,78 @@ register( ns: Namespace & SettingsNamespaceIn installSection( owner: Context, ns: Namespace & SettingsNamespaceInput, schema: z, entry: T, hooks: SettingsSectionHooks, ): void /** - * Describe every registered namespace for configuration surfaces, including - * the composition `base` and raw user layers so a form can mark which fields - * the user overrode (presence in `user`) and what a reset returns to. - * @param options - redaction switch; wire surfaces must redact. - * @returns one descriptor per registered namespace, in registration order. + * Describe every namespace kind for configuration surfaces, under the + * global scope or one named scope: the composition `base` and raw user + * layers so a form can mark which fields the user overrode (presence in + * `user`) and what a reset returns to, and for a scoped read the + * `inherited` value the scope's own section is layered over. A kind with + * no instance under the requested scope is described from the kind alone. + * @param options - redaction switch (wire surfaces must redact) and scope. + * @returns one descriptor per namespace kind, in registration order. + * @throws {TypeError} when `scope` is not a well-formed scope id. */ describe(options?: SettingsDescribeOptions): SettingsDescriptor[] /** - * Read one registered namespace's resolved value. - * @param ns - the namespace to read. - * @returns the resolved value, or `undefined` while unregistered. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + * Every named scope some namespace is registered under, in first-seen order. + * @returns the scope ids. */ -get(ns: Namespace & SettingsNamespaceInput): unknown +scopes(): SettingsScopeId[] /** - * Merge a patch into one registered namespace's user layer, validate the - * resolved candidate, persist through the provider, then commit and emit. - * A validation failure rejects before anything is persisted. Writes to one - * namespace are serialized: concurrent updates apply in call order, each - * merging over the previous write's committed section. + * Read one registered namespace's resolved value. + * @param ns - the namespace to read. + * @param scope - the named scope of the instance; the global instance when omitted. + * @returns the resolved value, or `undefined` while unregistered under that scope. + * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + */ +get(ns: Namespace & SettingsNamespaceInput, scope?: string): unknown + +/** + * Merge a patch into one namespace's user section, validate the resolved + * candidates, persist through the provider, then commit and emit. A + * validation failure rejects before anything is persisted. Writes to one + * section are serialized: concurrent updates apply in call order, each + * merging over the previous write's committed section. A global write + * re-resolves every instance of the kind; a scoped write only that scope's. * @param ns - the registered namespace to update. * @param patch - plain-object patch over the user section. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ -async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, ): Promise +async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, scope?: string, ): Promise /** - * Replace one registered namespace's user section wholesale, validate, - * persist, then commit and emit. Keys absent from `section` fall back to the - * composition `base` and schema defaults — this is the removal/reset path a - * merge-only patch cannot express (`replace({})` re-inherits everything). + * Replace one namespace's user section wholesale, validate, persist, then + * commit and emit. Keys absent from `section` fall back to the layers + * below — this is the removal/reset path a merge-only patch cannot express + * (`replace({})` re-inherits everything). * @param ns - the registered namespace to replace. * @param section - the complete next user section. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ -async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, ): Promise +async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, scope?: string, ): Promise /** - * Apply path-addressed edits to one registered namespace's user section, - * validate, persist, then commit and emit. The ops are applied to the - * section as it stands when the write reaches the front of the queue, so a - * caller never has to restate fields it did not touch — and, crucially, - * cannot delete fields it never saw. This is the write path for any caller - * holding a redacted view; `replace` remains the wholesale reset. + * Apply path-addressed edits to one namespace's user section, validate, + * persist, then commit and emit. The ops are applied to the section as it + * stands when the write reaches the front of the queue, so a caller never + * has to restate fields it did not touch — and, crucially, cannot delete + * fields it never saw. This is the write path for any caller holding a + * redacted view; `replace` remains the wholesale reset. * @param ns - the registered namespace to edit. * @param ops - ordered path edits; later ops observe earlier ones. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ -async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, ): Promise +async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, scope?: string, ): Promise ``` Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) @@ -284,12 +333,15 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote ```ts cordis-catalog /** - * Describe every registered namespace for a configuration page: redacted - * layered values plus the serialized schema the page renders its form from. - * @returns provider writability, local-document presence, and one view per namespace. - * @throws RemoteError when no settings provider is mounted. + * Describe every namespace kind for a configuration page: redacted layered + * values plus the serialized schema the page renders its form from, under + * the global scope or one named scope (an agent preset's `preset/`). + * @param scope - the named scope to describe; the global scope when omitted. + * @returns provider writability, local-document presence, one view per + * namespace kind, and every scope some namespace is registered under. + * @throws RemoteError when no settings provider is mounted or the scope id is malformed. */ -@Remote describe(): SettingsDescribeValue +@Remote describe(scope?: string): SettingsDescribeValue /** * Report whether this deployment can open an authored Agent preset directory natively. @@ -302,20 +354,22 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param ns - namespace key to write. * @param patch - fields to merge into the user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ -@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise +@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, scope?: string, ): Promise /** * Replace one namespace's stored user section wholesale. * @param ns - namespace key to write. * @param section - complete replacement user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ -@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise +@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, scope?: string, ): Promise /** * Apply path-addressed edits to one namespace's user section, resolved against @@ -324,10 +378,11 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param ns - namespace key to write. * @param ops - the edits to apply, in order. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ -@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise +@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, scope?: string, ): Promise /** * Materialize the provider-owned settings document and open it in a native text editor. @@ -368,10 +423,11 @@ One registered namespace's RAW user section changed, whether or not the resolved * resolved value, different meaning) and that their held revision is * stale. Listener containment matches `settings/updated`. * @param ns - the namespace whose stored section changed. - * @param revision - the namespace's new revision. + * @param revision - the section's new revision. + * @param scope - the named scope whose section changed; absent for the global section. * @mode emit */ -'settings/document-updated'(ns: SettingsNamespace, revision: number): void +'settings/document-updated'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void ``` Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts) @@ -396,9 +452,10 @@ Committed change to one registered namespace's resolved value. Emitted after the * @param next - the new resolved value. * @param prev - the previous resolved value. * @param source - whether the change entered through `update()` or the provider. + * @param scope - the named scope whose registration changed; absent for the global scope. * @mode emit */ -'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void +'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void ``` Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts) diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 772fd7832f..b553733293 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'> ## 注册 -注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose(资源释放)该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。 +注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose(资源释放)该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。namespace 是一种设置的 kind;每次注册是它在调用方最近的具名 `dsh-scope` 作用域(agent preset 的 `preset/`,或全局作用域)下的一个 instance,同一 kind 的每个注册者共享其 schema。instance 按顺序解析 schema 默认值、自己的 `base`、文档的全局分节与其作用域自己的分节。 ```ts type-equiv /** Registration options beyond the namespace schema. */ @@ -43,6 +43,9 @@ interface SettingsRegisterOptions { * registration there is no last good value yet, so a stored section that * already fails rejects the registration itself — again exactly as a schema * failure does. + * + * The check belongs to the namespace kind: the first registrant's check + * judges every instance, because every instance is the same plugin. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void @@ -65,7 +68,7 @@ scope 是面向 owner 的句柄。`update` 把稀疏 patch 只合并进用户分 ```ts type-equiv /** Owner-facing handle for one registered namespace. */ interface SettingsScope { - /** Current resolved value: schema defaults, then `base`, then the user layer. */ + /** Current resolved value: schema defaults, then `base`, then the user layers. */ get(): T /** * Observe committed changes to this namespace's resolved value. Invocations @@ -78,14 +81,15 @@ interface SettingsScope { */ watch(callback: (next: T, prev: T) => void | Promise): () => void /** - * Merge a partial patch into this namespace's user layer and persist it. + * Merge a partial patch into this registration's user section — the scope's + * own section for a scoped registration — and persist it. * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** - * Replace this namespace's user section wholesale; absent keys re-inherit - * the composition `base` and schema defaults (`replace({})` resets all). + * Replace this registration's user section wholesale; absent keys re-inherit + * the layers below (`replace({})` resets the section). * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ @@ -100,8 +104,18 @@ interface SettingsScope { ```ts type-equiv /** One registered namespace as surfaced to configuration UIs. */ interface SettingsDescriptor { + // TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the + // public API, provider contract, implementations, tests, and consumers. /** The registered namespace. */ ns: SettingsNamespace + /** The named scope the descriptor resolves under; absent for the global scope. */ + scope?: SettingsScopeId + /** + * Whether an owner registered the namespace under this scope. False for a + * scope described from the kind alone — a preset no session composed yet — + * whose value then carries no composition `base`. + */ + registered: boolean /** Serialized schemastery schema (`schema.toJSON()`). */ schema: unknown /** Current resolved value. */ @@ -116,8 +130,15 @@ interface SettingsDescriptor { /** * Raw user section from the stored document (detached), when one exists and * is well-formed; a field's presence here is what marks it user-overridden. + * For a scoped descriptor this is the scope's own section, not the global one. */ user?: unknown + /** + * For a scoped descriptor: the value the scope resolves without its own + * user section — defaults, base, and the global section — so a surface can + * tell a field the scope overrides from one it inherits. + */ + inherited?: unknown /** Owner's declared effect timing. */ applies: SettingsApplies /** Schema-declared secret positions; present only under `redactSecrets`. */ @@ -149,6 +170,12 @@ interface SettingsDescribeOptions { * the verbatim default exists for same-process configuration UIs only. */ redactSecrets?: boolean + /** + * Describe every namespace kind under this named scope instead of the + * global scope. A kind with no registration under the scope is described + * from the kind alone, `registered: false`. + */ + scope?: string } ``` @@ -191,13 +218,20 @@ prepareDocument(): Promise /** * Register a namespace schema and receive its owner scope. The registration * is an effect on the calling plugin's fiber: disposing that fiber removes - * the namespace and its observers. An invalid stored section fails the + * the instance and its observers. An invalid stored section fails the * registration itself — the earliest point where the schema can judge it. - * @param ns - unique namespace; duplicate registration fails loud. + * + * The instance registers under the caller's nearest named scope: a plugin + * mounted inside an agent preset resolves that preset's section over the + * global one, and two presets mounting the same plugin hold two instances + * of one kind. A second registrant of a namespace must carry the same + * schema envelope; a different one is a different setting under a taken + * name and fails loud. + * @param ns - the namespace; a second registration under the same scope fails loud. * @param schema - schemastery schema resolving this namespace's value. * @param options - composition `base` layer and effect timing. * @returns the owner scope for reads, observation, and updates. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved. */ register( ns: Namespace & SettingsNamespaceInput, schema: z, options?: SettingsRegisterOptions, ): SettingsScope @@ -215,63 +249,78 @@ register( ns: Namespace & SettingsNamespaceIn installSection( owner: Context, ns: Namespace & SettingsNamespaceInput, schema: z, entry: T, hooks: SettingsSectionHooks, ): void /** - * Describe every registered namespace for configuration surfaces, including - * the composition `base` and raw user layers so a form can mark which fields - * the user overrode (presence in `user`) and what a reset returns to. - * @param options - redaction switch; wire surfaces must redact. - * @returns one descriptor per registered namespace, in registration order. + * Describe every namespace kind for configuration surfaces, under the + * global scope or one named scope: the composition `base` and raw user + * layers so a form can mark which fields the user overrode (presence in + * `user`) and what a reset returns to, and for a scoped read the + * `inherited` value the scope's own section is layered over. A kind with + * no instance under the requested scope is described from the kind alone. + * @param options - redaction switch (wire surfaces must redact) and scope. + * @returns one descriptor per namespace kind, in registration order. + * @throws {TypeError} when `scope` is not a well-formed scope id. */ describe(options?: SettingsDescribeOptions): SettingsDescriptor[] /** - * Read one registered namespace's resolved value. - * @param ns - the namespace to read. - * @returns the resolved value, or `undefined` while unregistered. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + * Every named scope some namespace is registered under, in first-seen order. + * @returns the scope ids. */ -get(ns: Namespace & SettingsNamespaceInput): unknown +scopes(): SettingsScopeId[] /** - * Merge a patch into one registered namespace's user layer, validate the - * resolved candidate, persist through the provider, then commit and emit. - * A validation failure rejects before anything is persisted. Writes to one - * namespace are serialized: concurrent updates apply in call order, each - * merging over the previous write's committed section. + * Read one registered namespace's resolved value. + * @param ns - the namespace to read. + * @param scope - the named scope of the instance; the global instance when omitted. + * @returns the resolved value, or `undefined` while unregistered under that scope. + * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + */ +get(ns: Namespace & SettingsNamespaceInput, scope?: string): unknown + +/** + * Merge a patch into one namespace's user section, validate the resolved + * candidates, persist through the provider, then commit and emit. A + * validation failure rejects before anything is persisted. Writes to one + * section are serialized: concurrent updates apply in call order, each + * merging over the previous write's committed section. A global write + * re-resolves every instance of the kind; a scoped write only that scope's. * @param ns - the registered namespace to update. * @param patch - plain-object patch over the user section. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ -async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, ): Promise +async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, scope?: string, ): Promise /** - * Replace one registered namespace's user section wholesale, validate, - * persist, then commit and emit. Keys absent from `section` fall back to the - * composition `base` and schema defaults — this is the removal/reset path a - * merge-only patch cannot express (`replace({})` re-inherits everything). + * Replace one namespace's user section wholesale, validate, persist, then + * commit and emit. Keys absent from `section` fall back to the layers + * below — this is the removal/reset path a merge-only patch cannot express + * (`replace({})` re-inherits everything). * @param ns - the registered namespace to replace. * @param section - the complete next user section. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ -async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, ): Promise +async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, scope?: string, ): Promise /** - * Apply path-addressed edits to one registered namespace's user section, - * validate, persist, then commit and emit. The ops are applied to the - * section as it stands when the write reaches the front of the queue, so a - * caller never has to restate fields it did not touch — and, crucially, - * cannot delete fields it never saw. This is the write path for any caller - * holding a redacted view; `replace` remains the wholesale reset. + * Apply path-addressed edits to one namespace's user section, validate, + * persist, then commit and emit. The ops are applied to the section as it + * stands when the write reaches the front of the queue, so a caller never + * has to restate fields it did not touch — and, crucially, cannot delete + * fields it never saw. This is the write path for any caller holding a + * redacted view; `replace` remains the wholesale reset. * @param ns - the registered namespace to edit. * @param ops - ordered path edits; later ops observe earlier ones. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ -async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, ): Promise +async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, scope?: string, ): Promise ``` Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) @@ -284,12 +333,15 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote ```ts cordis-catalog /** - * Describe every registered namespace for a configuration page: redacted - * layered values plus the serialized schema the page renders its form from. - * @returns provider writability, local-document presence, and one view per namespace. - * @throws RemoteError when no settings provider is mounted. + * Describe every namespace kind for a configuration page: redacted layered + * values plus the serialized schema the page renders its form from, under + * the global scope or one named scope (an agent preset's `preset/`). + * @param scope - the named scope to describe; the global scope when omitted. + * @returns provider writability, local-document presence, one view per + * namespace kind, and every scope some namespace is registered under. + * @throws RemoteError when no settings provider is mounted or the scope id is malformed. */ -@Remote describe(): SettingsDescribeValue +@Remote describe(scope?: string): SettingsDescribeValue /** * Report whether this deployment can open an authored Agent preset directory natively. @@ -302,20 +354,22 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param ns - namespace key to write. * @param patch - fields to merge into the user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ -@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise +@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, scope?: string, ): Promise /** * Replace one namespace's stored user section wholesale. * @param ns - namespace key to write. * @param section - complete replacement user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ -@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise +@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, scope?: string, ): Promise /** * Apply path-addressed edits to one namespace's user section, resolved against @@ -324,10 +378,11 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param ns - namespace key to write. * @param ops - the edits to apply, in order. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ -@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise +@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, scope?: string, ): Promise /** * Materialize the provider-owned settings document and open it in a native text editor. @@ -368,10 +423,11 @@ One registered namespace's RAW user section changed, whether or not the resolved * resolved value, different meaning) and that their held revision is * stale. Listener containment matches `settings/updated`. * @param ns - the namespace whose stored section changed. - * @param revision - the namespace's new revision. + * @param revision - the section's new revision. + * @param scope - the named scope whose section changed; absent for the global section. * @mode emit */ -'settings/document-updated'(ns: SettingsNamespace, revision: number): void +'settings/document-updated'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void ``` Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts) @@ -396,9 +452,10 @@ Committed change to one registered namespace's resolved value. Emitted after the * @param next - the new resolved value. * @param prev - the previous resolved value. * @param source - whether the change entered through `update()` or the provider. + * @param scope - the named scope whose registration changed; absent for the global scope. * @mode emit */ -'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void +'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void ``` Source: [`packages/settings/settings/src/types.ts`](../../packages/settings/settings/src/types.ts) diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 045d7b8753..0c6c1cdd86 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/README.i18n.yaml @@ -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/api/settings-controller/README.md -README.md: 761e4c43751675eec141a2efcf85caf52cc91590 -README.zh.md: 541c9deb3dbc35914e4e95abdda2af5cdce2bbd0 +README.md: 3a458bbb0cd20730cb530195883d13afc601dbe7 +README.zh.md: 554cd258d0c2789b4a379714cd8023f4cbf72118 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index 761e4c4375..3a458bbb0c 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -27,7 +27,7 @@ Mount this package as a Loader entry in a profile that serves browser configurat `describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one. -`settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. +`settings.describe(scope?)` returns deployment facts and every namespace kind under `redactSecrets: true` — under the global scope, or under one named scope such as an agent preset's `preset/`, where each view says whether an owner registered the namespace there and carries the `inherited` value the scope's own section is layered over — together with every scope some namespace is registered under. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations, take the same optional trailing `scope`, and return the namespace's new redacted view under that scope; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. `settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.canOpenAgentPresetDirectory()` reports native-opening availability when the preset page becomes visible. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither open method accepts a browser-supplied filesystem target. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 541c9deb3d..554cd258d0 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" `describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——provider 返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把 provider 拒绝报告为 `credential-rejected`,携带 provider 的消息,details 中只有该引用。密钥值只在这个方向跨越 wire:这里没有任何方法会返回它。 -`settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,并返回该 namespace 的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。 +`settings.describe(scope?)` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace kind——在全局 scope 下,或在某个具名 scope(如 agent preset 的 `preset/`)下,此时每条视图都说明该 scope 下是否有 owner 注册了此 namespace,并携带该 scope 自己分节所叠加的 `inherited` 值——连同所有有 namespace 注册于其下的 scope。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,接受同样的可选尾随 `scope`,并返回该 namespace 在该 scope 下的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。 `settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.canOpenAgentPresetDirectory()` 在 preset 页面显示时报告原生打开能力。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两个打开方法都不接受浏览器提供的文件系统目标。 diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 074a5017c7..67dd8f6d12 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" @@ -61,6 +62,7 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index c5e9037ab3..4899522a97 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -30,7 +30,7 @@ import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from '. export { CredentialsController } from './credentials.ts' export type * from './types.ts' -const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) +const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1), scope: z.string().min(1).optional() }) /** Native document-opening policy. */ export interface Config { @@ -61,10 +61,13 @@ export interface SettingsControllerInternals { function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView { return { ns: String(descriptor.ns), + ...descriptor.scope === undefined ? {} : { scope: String(descriptor.scope) }, + registered: descriptor.registered, schema: descriptor.schema as JsonValue, value: descriptor.value as JsonValue, ...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue }, ...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue }, + ...descriptor.inherited === undefined ? {} : { inherited: descriptor.inherited as JsonValue }, applies: descriptor.applies, secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })), revision: descriptor.revision, @@ -108,18 +111,29 @@ export class SettingsController extends TypertRemoteService { } /** - * Describe every registered namespace for a configuration page: redacted - * layered values plus the serialized schema the page renders its form from. - * @returns provider writability, local-document presence, and one view per namespace. - * @throws RemoteError when no settings provider is mounted. + * Describe every namespace kind for a configuration page: redacted layered + * values plus the serialized schema the page renders its form from, under + * the global scope or one named scope (an agent preset's `preset/`). + * @param scope - the named scope to describe; the global scope when omitted. + * @returns provider writability, local-document presence, one view per + * namespace kind, and every scope some namespace is registered under. + * @throws RemoteError when no settings provider is mounted or the scope id is malformed. */ @Remote - describe(): SettingsDescribeValue { + describe(scope?: string): SettingsDescribeValue { const settings = this.provider() + let views: SettingsNamespaceView[] + try { + views = settings.describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } }).map(namespaceView) + } catch (error: unknown) { + throw new RemoteError('gateway/bad-request', `invalid scope for settings.describe: ${messageOf(error)}`, {}, { cause: error }) + } return { writable: settings.writable, hasDocument: settings.documentPath !== undefined, - namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), + namespaces: views, + ...scope === undefined ? {} : { scope }, + scopes: settings.scopes().map(String), } } @@ -137,7 +151,8 @@ export class SettingsController extends TypertRemoteService { * @param ns - namespace key to write. * @param patch - fields to merge into the user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote @@ -145,8 +160,9 @@ export class SettingsController extends TypertRemoteService { ns: string, patch: Record, expectedRevision: number | undefined, + scope?: string, ): Promise { - return this.write(ns, 'update', patch, expectedRevision) + return this.write(ns, 'update', patch, expectedRevision, scope) } /** @@ -154,7 +170,8 @@ export class SettingsController extends TypertRemoteService { * @param ns - namespace key to write. * @param section - complete replacement user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote @@ -162,8 +179,9 @@ export class SettingsController extends TypertRemoteService { ns: string, section: Record, expectedRevision: number | undefined, + scope?: string, ): Promise { - return this.write(ns, 'replace', section, expectedRevision) + return this.write(ns, 'replace', section, expectedRevision, scope) } /** @@ -173,7 +191,8 @@ export class SettingsController extends TypertRemoteService { * @param ns - namespace key to write. * @param ops - the edits to apply, in order. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. - * @returns the namespace's redacted view after the write. + * @param scope - the named scope whose section to write; the global section when omitted. + * @returns the namespace's redacted view under that scope after the write. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote @@ -181,8 +200,9 @@ export class SettingsController extends TypertRemoteService { ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, + scope?: string, ): Promise { - return this.write(ns, 'mutate', ops, expectedRevision) + return this.write(ns, 'mutate', ops, expectedRevision, scope) } /** @@ -262,21 +282,24 @@ export class SettingsController extends TypertRemoteService { mode: 'update' | 'replace' | 'mutate', input: Record | SettingsPathOpView[], expectedRevision: number | undefined, + scope: string | undefined, ): Promise { - const parsed = settingsNamespaceRequestSchema.safeParse({ ns }) + const parsed = settingsNamespaceRequestSchema.safeParse({ ns, ...scope === undefined ? {} : { scope } }) if (!parsed.success) { throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues }) } const settings = this.provider() const namespace = parsed.data.ns try { - if (mode === 'update') await settings.update(namespace, input, expectedRevision) - else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision) - else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision) + if (mode === 'update') await settings.update(namespace, input, expectedRevision, scope) + else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision, scope) + else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision, scope) } catch (error: unknown) { throw rejected(ns, error) } - const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === namespace) + const descriptor = settings + .describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } }) + .find(candidate => candidate.ns === namespace) if (descriptor === undefined) { // The write committed but the namespace vanished before this read: only a // concurrent registrant disposal can produce it. diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index c5df993b59..33739eaf61 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -432,3 +432,47 @@ describe('the settings Remote namespace a configuration page calls', () => { .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: desktop unavailable' }) }) }) + +describe('scoped reads and writes', () => { + it('describes one named scope from the kind, lists every registered scope, and refuses a malformed scope id', async () => { + const { controller, ctx } = await boot(MemorySettings, { doc: { [NS]: { preference: 'dark' } } }) + const { createScope } = await import('@deepseek-ai/dsh-scope') + let scoped!: Awaited> + await ctx.plugin((host: Context) => { scoped = createScope(host, { id: 'standard' }, { id: 'preset/standard' }) }) + await scoped.ctx.plugin({ inject: ['settings'], apply(inner: Context) { inner.settings.register(NS, Profile, { base: { preference: 'light' } }) } }) + + const global = controller.describe() + expect(global.scopes).toEqual(['preset/standard']) + expect(global.scope).toBeUndefined() + expect(global.namespaces[0]).toMatchObject({ ns: NS, registered: true, value: { preference: 'dark' } }) + + const standard = controller.describe('preset/standard') + expect(standard.scope).toBe('preset/standard') + expect(standard.namespaces[0]).toMatchObject({ + ns: NS, scope: 'preset/standard', registered: true, base: { preference: 'light' }, inherited: { preference: 'dark' }, value: { preference: 'dark' }, + }) + const other = controller.describe('preset/other') + expect(other.namespaces[0]).toMatchObject({ ns: NS, scope: 'preset/other', registered: false, inherited: { preference: 'dark' } }) + expect(other.namespaces[0]?.base).toBeUndefined() + + const failure = await Promise.resolve().then(() => controller.describe('Not Valid')).catch((error: unknown) => error) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) + }) + + it('writes one scope\'s section and answers with that scope\'s view', async () => { + const { controller, ctx } = await boot(MemorySettings, { doc: { [NS]: { preference: 'dark' } } }) + const provider = ctx.get('settings') as MemorySettings + + const updated = await controller.update(NS, { preference: 'light' }, undefined, 'preset/standard') + expect(updated).toMatchObject({ ns: NS, scope: 'preset/standard', registered: false, value: { preference: 'light' }, user: { preference: 'light' }, revision: 1 }) + expect(provider.doc).toEqual({ [NS]: { preference: 'dark' }, scopes: { 'preset/standard': { [NS]: { preference: 'light' } } } }) + + const replaced = await controller.replace(NS, {}, 1, 'preset/standard') + expect(replaced).toMatchObject({ scope: 'preset/standard', value: { preference: 'dark' }, revision: 2 }) + const mutated = await controller.mutate(NS, [{ op: 'set', path: ['preference'], value: 'light' }], undefined, 'preset/standard') + expect(mutated).toMatchObject({ scope: 'preset/standard', value: { preference: 'light' }, revision: 3 }) + + const failure = await controller.update(NS, {}, undefined, '').catch((error: unknown) => error) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) + }) +}) diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json index 0ea09072a4..00c59f6dcc 100644 --- a/packages/api/settings-controller/tsconfig.json +++ b/packages/api/settings-controller/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../util/native-command" }, + { + "path": "../../core/scope" + }, { "path": "../../settings/settings" }, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index d04eebcf47..b420462dbf 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1844,8 +1844,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: { writable: true, hasDocument: true, + scopes: [], namespaces: [{ ns: 'llm-deepseek', + registered: true, schema: {}, value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, applies: 'live', diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index b083a4074a..3d07c345e9 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -34,6 +34,7 @@ const SCHEMA = { function view(defaultPreset: string, revision = 0): SettingsNamespaceView { return { ns: 'permission', + registered: true, schema: SCHEMA, value: { defaultPreset }, base: { defaultPreset: 'read-only' }, diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index c999d9ee1a..82f2b55dea 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -27,6 +27,7 @@ function resolveDefault(view: SettingsNamespaceView) { function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView { return { ns: 'permission', + registered: true, schema, value: { defaultPreset }, base: { defaultPreset: 'read-only' }, diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 6d100d800e..82ad433c23 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -94,6 +94,7 @@ function wireNamespaces(): SettingsNamespaceView[] { return [ { ns: 'llm-deepseek', + registered: true, schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value: { apiKeyEnv: 'DEEPSEEK_API_KEY', @@ -110,6 +111,7 @@ function wireNamespaces(): SettingsNamespaceView[] { }, { ns: 'llm-plain', + registered: true, schema: JSON.parse(JSON.stringify(Schema.object({ profiles: Schema.dict(Schema.object({ note: Schema.string() })), }).toJSON())) as JsonValue, @@ -120,6 +122,7 @@ function wireNamespaces(): SettingsNamespaceView[] { }, { ns: 'llm-pi-ai', + registered: true, schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue, value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, @@ -129,6 +132,7 @@ function wireNamespaces(): SettingsNamespaceView[] { }, { ns: 'subagent-model-selection', + registered: true, schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as JsonValue, value: { enabled: false }, applies: 'live', @@ -749,6 +753,7 @@ describe('ModelsSection', () => { const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] } const overridden: SettingsNamespaceView = { ns: 'llm-deepseek', + registered: true, schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value: { ...stored, defaultContextWindow: 1_000_000 }, ...base === undefined ? {} : { base }, @@ -981,6 +986,7 @@ describe('ModelsSection', () => { const { face } = scriptedFace() const bare: SettingsNamespaceView = { ns: 'llm-deepseek', + registered: true, schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value: {}, applies: 'live', diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 996b0ff2f4..8d5cb36c7e 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -48,6 +48,7 @@ function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView { const value = apiKeyEnv === null ? {} : { apiKeyEnv } return { ns: 'llm-deepseek', + registered: true, schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value, base: value, diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 1fb842477e..36cc9b04db 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -80,6 +80,7 @@ function piAiNamespace( ): SettingsNamespaceView { return { ns: 'llm-pi-ai', + registered: true, schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue, // `value` is the effective section; `user` is only the layer this page // writes. They differ whenever a composition `base` supplies something. diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 5f4e13259d..0d96b98ed5 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -1137,7 +1137,7 @@ describe('ConfigurablePluginsTabController', () => { writable: true, hasDocument: true, namespaces: [{ - ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1, + ns: 'bash', registered: true, schema: {}, value: {}, applies: 'live', secrets: [], revision: 1, }], }, error: null, diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 3bb67fe3f0..fb28e30d09 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -22,7 +22,7 @@ function ctxWith(describeCall: unknown) { } function view(ns: string, revision = 0): SettingsNamespaceView { - return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision } + return { ns, registered: true, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision } } function described(namespaces: SettingsNamespaceView[]): Answer { diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 1eef32e6f0..bed7d719a1 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -42,6 +42,7 @@ function ctxWith(settings: object) { function view(value: JsonValue, revision = 0): SettingsNamespaceView { return { ns: 'ui-test', + registered: true, // `toJSON()` already produced the wire envelope; its declared type is the // schema builder's, so one cast names what the Host actually sends. schema: ENVELOPE as unknown as JsonValue, diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index 2d63e51040..b781f4eae8 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/README.i18n.yaml @@ -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/core/scope/README.md -README.md: 20027403a88ddd5cd1f85ec5b907a8c88a260da7 -README.zh.md: f98eb5faaa618a5f3b2f0c9859da2320d6f6b2e0 +README.md: e5c6e6b172f75debfc3a5afdaafd84a3dc670e63 +README.zh.md: 7cf486ba69da9e7d8566dbf0e6914d6f74ec64c9 diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 20027403a8..e5c6e6b172 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -28,7 +28,7 @@ Plugin authors use `dsh-scope` to give one agent (or one group) its own registra ### Mint a scope -`createScope(ctx, key)` creates a scope under `ctx`'s fiber: its `ctx` carries the scope tag, and everything registered through it is both scope-visible and scope-lifetime. `dispose()` unwinds every registration through the scope; `rawDispose` is the exact Cordis disposer for nesting the teardown in an ordered composite effect. +`createScope(ctx, key)` creates a scope under `ctx`'s fiber: its `ctx` carries the scope tag, and everything registered through it is both scope-visible and scope-lifetime. `dispose()` unwinds every registration through the scope; `rawDispose` is the exact Cordis disposer for nesting the teardown in an ordered composite effect. Pass `id` (`createScope(ctx, key, { id: 'preset/standard' })`) to name the scope: `scopeIdOf(ctx)` then answers the nearest named scope along a context's parent chain — an agent's context resolves to the preset it joined — which is the name consumers keeping per-scope state outside the process, such as the settings document, address it by; `scopeIdOfKey(key)` reads one key's own name. ```text const scope = createScope(ctx, agent) @@ -63,7 +63,7 @@ The registration context determines both visibility and ownership: a registratio | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `createScope`, `scopeOf`, `scopeTarget`, `bindScopeParent`/`scopeParentOf`/`scopeChainOf`, carrier marks | +| [`src/index.ts`](src/index.ts) | `createScope` (with named scopes), `scopeOf`, `scopeIdOf`/`scopeIdOfKey`, `scopeTarget`, `bindScopeParent`/`scopeParentOf`/`scopeChainOf`, carrier marks | | [`src/store.ts`](src/store.ts) | `ScopedLayers`, `NamedEntries`, `AnonymousEntries`, `ScopeLayer` | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion over the generated scoped-event map | | [`src/scoped-events.generated.ts`](src/scoped-events.generated.ts) | Generated resolver map of declared scoped events | diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index f98eb5faaa..7cf486ba69 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" ### 创建作用域 -`createScope(ctx, key)` 在 `ctx` 的 fiber 下创建作用域:其 `ctx` 携带作用域标签,通过它进行的每项注册既具备作用域可见性,也服从作用域生命周期。`dispose()` 撤销通过该作用域进行的每项注册;`rawDispose` 是确切 Cordis disposer,用于把 teardown 嵌套进有序组合 effect。 +`createScope(ctx, key)` 在 `ctx` 的 fiber 下创建作用域:其 `ctx` 携带作用域标签,通过它进行的每项注册既具备作用域可见性,也服从作用域生命周期。`dispose()` 撤销通过该作用域进行的每项注册;`rawDispose` 是确切 Cordis disposer,用于把 teardown 嵌套进有序组合 effect。传入 `id`(`createScope(ctx, key, { id: 'preset/standard' })`)给作用域起名:此后 `scopeIdOf(ctx)` 沿上下文的父链回答最近的具名作用域——agent 的上下文解析到它加入的 preset——这正是把按作用域状态放在进程之外(如 settings 文档)的消费方用来寻址它的名字;`scopeIdOfKey(key)` 读取单个 key 自己的名字。 ```text const scope = createScope(ctx, agent) @@ -63,7 +63,7 @@ await scope.dispose() // unwinds every registration made through scope.ctx | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `createScope`、`scopeOf`、`scopeTarget`、`bindScopeParent`/`scopeParentOf`/`scopeChainOf`、载体标记 | +| [`src/index.ts`](src/index.ts) | `createScope`(含具名作用域)、`scopeOf`、`scopeIdOf`/`scopeIdOfKey`、`scopeTarget`、`bindScopeParent`/`scopeParentOf`/`scopeChainOf`、载体标记 | | [`src/store.ts`](src/store.ts) | `ScopedLayers`、`NamedEntries`、`AnonymousEntries`、`ScopeLayer` | | [`src/invariant.ts`](src/invariant.ts) | 基于生成的作用域事件映射的不变式配套 | | [`src/scoped-events.generated.ts`](src/scoped-events.generated.ts) | 已声明带作用域事件的生成解析器映射 | diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index ef64440ac4..42590d5014 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -120,10 +120,23 @@ async function quiesceFiber(fiber: Fiber): Promise { /** Shared no-op plugin used as the backing scope fiber. */ function scope(): void {} +/** + * The stable name of each named scope key. A name is what a process-external + * record — a settings document section, a log line — can refer to a scope by, + * where the key object itself cannot travel. + */ +const scopeIds = new WeakMap() + /** Options accepted by {@link createScope}. */ export interface CreateScopeOptions { /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */ parent?: ScopeKey + /** + * Stable name for the scope, such as `preset/standard`. Named scopes are + * addressable by consumers that keep per-scope state outside the process + * ({@link scopeIdOf}); an unnamed scope is addressable by its key alone. + */ + id?: string } /** @@ -136,6 +149,10 @@ export interface CreateScopeOptions { */ export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { if (options?.parent !== undefined) bindScopeParent(key, options.parent) + if (options?.id !== undefined) { + if (options.id.length === 0) throw new Error('dsh-scope: a scope id must not be empty') + scopeIds.set(key, options.id) + } const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined @@ -155,6 +172,31 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { return (ctx as Context & { [kScope]?: ScopeKey })[kScope] } +/** + * The name of one scope key, when {@link createScope} gave it one. + * @param key - the scope key to name. + * @returns the id, or `undefined` for an unnamed key. + */ +export function scopeIdOfKey(key: ScopeKey): string | undefined { + return scopeIds.get(key) +} + +/** + * The nearest named scope a context belongs to: its own scope when that is + * named, else the nearest named ancestor along the parent chain + * ({@link bindScopeParent}). An agent's context therefore resolves to the + * preset it joined, which is the name per-scope state is kept under. + * @param ctx - context to inspect. + * @returns the nearest named scope's id, or `undefined` when none of the chain is named. + */ +export function scopeIdOf(ctx: Context): string | undefined { + for (const key of scopeChainOf(scopeOf(ctx))) { + const id = scopeIds.get(key) + if (id !== undefined) return id + } + return undefined +} + /** * Build an opaque receiver that preserves the base filter, admits untagged * listeners globally, and admits tagged listeners for a matching key or any diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index bb0361fbf2..38c38b3cc7 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -220,3 +220,30 @@ describe('scope parent chain', () => { expect(seen.sort()).toEqual(['preset', 'untagged']) }) }) + +describe('named scopes', () => { + it('names a scope and resolves the nearest named scope along the chain', async () => { + const { createScope: mint, scopeIdOf: idOf, scopeIdOfKey: idOfKey } = await import('@deepseek-ai/dsh-scope') + const ctx = new Context() + const presetKey = { name: 'preset' } + const agentKey = { name: 'agent' } + const nestedKey = { name: 'nested' } + let preset!: Scope + await ctx.plugin((inner: Context) => { preset = mint(inner, presetKey, { id: 'preset/standard' }) }) + const agent = mint(preset.ctx, agentKey, { parent: presetKey }) + const nested = mint(agent.ctx, nestedKey, { parent: agentKey, id: 'session/one' }) + + expect(idOfKey(presetKey)).toBe('preset/standard') + expect(idOfKey(agentKey)).toBeUndefined() + expect(idOf(ctx)).toBeUndefined() + expect(idOf(preset.ctx)).toBe('preset/standard') + // An unnamed scope resolves to its nearest named ancestor; a named one to itself. + expect(idOf(agent.ctx)).toBe('preset/standard') + expect(idOf(nested.ctx)).toBe('session/one') + expect(() => mint(ctx, { name: 'empty' }, { id: '' })).toThrow('must not be empty') + + await nested.dispose() + await agent.dispose() + await preset.dispose() + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9ac6e8c2d9..4aea91fba1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1995,10 +1995,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'register( ns: Namespace & SettingsNamespaceInput, schema: z, options?: SettingsRegisterOptions, ): SettingsScope', - description: 'Register a namespace schema and receive its owner scope. The registration is an effect on the calling plugin\'s fiber: disposing that fiber removes the namespace and its observers. An invalid stored section fails the registration itself — the earliest point where the schema can judge it.', - parameters: [{ name: 'ns', description: 'unique namespace; duplicate registration fails loud.' }, { name: 'schema', description: 'schemastery schema resolving this namespace\'s value.' }, { name: 'options', description: 'composition `base` layer and effect timing.' }], + description: 'Register a namespace schema and receive its owner scope. The registration is an effect on the calling plugin\'s fiber: disposing that fiber removes the instance and its observers. An invalid stored section fails the registration itself — the earliest point where the schema can judge it.\n\nThe instance registers under the caller\'s nearest named scope: a plugin mounted inside an agent preset resolves that preset\'s section over the global one, and two presets mounting the same plugin hold two instances of one kind. A second registrant of a namespace must carry the same schema envelope; a different one is a different setting under a taken name and fails loud.', + parameters: [{ name: 'ns', description: 'the namespace; a second registration under the same scope fails loud.' }, { name: 'schema', description: 'schemastery schema resolving this namespace\'s value.' }, { name: 'options', description: 'composition `base` layer and effect timing.' }], returns: 'the owner scope for reads, observation, and updates.', - throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], + throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved.'], }, { signature: 'installSection( owner: Context, ns: Namespace & SettingsNamespaceInput, schema: z, entry: T, hooks: SettingsSectionHooks, ): void', @@ -2008,33 +2008,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'describe(options?: SettingsDescribeOptions): SettingsDescriptor[]', - description: 'Describe every registered namespace for configuration surfaces, including the composition `base` and raw user layers so a form can mark which fields the user overrode (presence in `user`) and what a reset returns to.', - parameters: [{ name: 'options', description: 'redaction switch; wire surfaces must redact.' }], - returns: 'one descriptor per registered namespace, in registration order.', + description: 'Describe every namespace kind for configuration surfaces, under the global scope or one named scope: the composition `base` and raw user layers so a form can mark which fields the user overrode (presence in `user`) and what a reset returns to, and for a scoped read the `inherited` value the scope\'s own section is layered over. A kind with no instance under the requested scope is described from the kind alone.', + parameters: [{ name: 'options', description: 'redaction switch (wire surfaces must redact) and scope.' }], + returns: 'one descriptor per namespace kind, in registration order.', + throws: ['{TypeError} when `scope` is not a well-formed scope id.'], }, { - signature: 'get(ns: Namespace & SettingsNamespaceInput): unknown', + signature: 'scopes(): SettingsScopeId[]', + description: 'Every named scope some namespace is registered under, in first-seen order.', + parameters: [], + returns: 'the scope ids.', + }, + { + signature: 'get(ns: Namespace & SettingsNamespaceInput, scope?: string): unknown', description: 'Read one registered namespace\'s resolved value.', - parameters: [{ name: 'ns', description: 'the namespace to read.' }], - returns: 'the resolved value, or `undefined` while unregistered.', + parameters: [{ name: 'ns', description: 'the namespace to read.' }, { name: 'scope', description: 'the named scope of the instance; the global instance when omitted.' }], + returns: 'the resolved value, or `undefined` while unregistered under that scope.', throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], }, { - signature: 'async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, ): Promise', - description: 'Merge a patch into one registered namespace\'s user layer, validate the resolved candidate, persist through the provider, then commit and emit. A validation failure rejects before anything is persisted. Writes to one namespace are serialized: concurrent updates apply in call order, each merging over the previous write\'s committed section.', - parameters: [{ name: 'ns', description: 'the registered namespace to update.' }, { name: 'patch', description: 'plain-object patch over the user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }], + signature: 'async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, scope?: string, ): Promise', + description: 'Merge a patch into one namespace\'s user section, validate the resolved candidates, persist through the provider, then commit and emit. A validation failure rejects before anything is persisted. Writes to one section are serialized: concurrent updates apply in call order, each merging over the previous write\'s committed section. A global write re-resolves every instance of the kind; a scoped write only that scope\'s.', + parameters: [{ name: 'ns', description: 'the registered namespace to update.' }, { name: 'patch', description: 'plain-object patch over the user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a section that moved past it rejects with {@link SettingsConflictError}.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }], throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], }, { - signature: 'async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, ): Promise', - description: 'Replace one registered namespace\'s user section wholesale, validate, persist, then commit and emit. Keys absent from `section` fall back to the composition `base` and schema defaults — this is the removal/reset path a merge-only patch cannot express (`replace({})` re-inherits everything).', - parameters: [{ name: 'ns', description: 'the registered namespace to replace.' }, { name: 'section', description: 'the complete next user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }], + signature: 'async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, scope?: string, ): Promise', + description: 'Replace one namespace\'s user section wholesale, validate, persist, then commit and emit. Keys absent from `section` fall back to the layers below — this is the removal/reset path a merge-only patch cannot express (`replace({})` re-inherits everything).', + parameters: [{ name: 'ns', description: 'the registered namespace to replace.' }, { name: 'section', description: 'the complete next user section.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a section that moved past it rejects with {@link SettingsConflictError}.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }], throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], }, { - signature: 'async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, ): Promise', - description: 'Apply path-addressed edits to one registered namespace\'s user section, validate, persist, then commit and emit. The ops are applied to the section as it stands when the write reaches the front of the queue, so a caller never has to restate fields it did not touch — and, crucially, cannot delete fields it never saw. This is the write path for any caller holding a redacted view; `replace` remains the wholesale reset.', - parameters: [{ name: 'ns', description: 'the registered namespace to edit.' }, { name: 'ops', description: 'ordered path edits; later ops observe earlier ones.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a namespace that moved past it rejects with {@link SettingsConflictError}.' }], + signature: 'async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, scope?: string, ): Promise', + description: 'Apply path-addressed edits to one namespace\'s user section, validate, persist, then commit and emit. The ops are applied to the section as it stands when the write reaches the front of the queue, so a caller never has to restate fields it did not touch — and, crucially, cannot delete fields it never saw. This is the write path for any caller holding a redacted view; `replace` remains the wholesale reset.', + parameters: [{ name: 'ns', description: 'the registered namespace to edit.' }, { name: 'ops', description: 'ordered path edits; later ops observe earlier ones.' }, { name: 'expectedRevision', description: 'the descriptor `revision` the caller read; a section that moved past it rejects with {@link SettingsConflictError}.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }], throws: ['{TypeError} when `ns` is not a lowercase hyphenated identifier.'], }, ], @@ -2045,11 +2052,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role(\'secret\')` field cannot ride a response. Writes expose the settings service\'s merge, replacement, and path-addressed operations, and classify every provider refusal as `settings/conflict` or `settings/rejected` with the service\'s message.', methods: [ { - signature: '@Remote describe(): SettingsDescribeValue', - description: 'Describe every registered namespace for a configuration page: redacted layered values plus the serialized schema the page renders its form from.', - parameters: [], - returns: 'provider writability, local-document presence, and one view per namespace.', - throws: ['RemoteError when no settings provider is mounted.'], + signature: '@Remote describe(scope?: string): SettingsDescribeValue', + description: 'Describe every namespace kind for a configuration page: redacted layered values plus the serialized schema the page renders its form from, under the global scope or one named scope (an agent preset\'s `preset/`).', + parameters: [{ name: 'scope', description: 'the named scope to describe; the global scope when omitted.' }], + returns: 'provider writability, local-document presence, one view per namespace kind, and every scope some namespace is registered under.', + throws: ['RemoteError when no settings provider is mounted or the scope id is malformed.'], }, { signature: '@Remote canOpenAgentPresetDirectory(): boolean', @@ -2058,24 +2065,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'true when the matching open operation is available.', }, { - signature: '@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise', + signature: '@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, scope?: string, ): Promise', description: 'Merge a patch into one namespace\'s stored user section.', - parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], - returns: 'the namespace\'s redacted view after the write.', + parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }], + returns: 'the namespace\'s redacted view under that scope after the write.', throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, { - signature: '@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise', + signature: '@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, scope?: string, ): Promise', description: 'Replace one namespace\'s stored user section wholesale.', - parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], - returns: 'the namespace\'s redacted view after the write.', + parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }], + returns: 'the namespace\'s redacted view under that scope after the write.', throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, { - signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise', + signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, scope?: string, ): Promise', description: 'Apply path-addressed edits to one namespace\'s user section, resolved against the section as stored rather than against whatever the caller last read, then answer with that namespace\'s new redacted view.', - parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], - returns: 'the namespace\'s redacted view after the write.', + parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }, { name: 'scope', description: 'the named scope whose section to write; the global section when omitted.' }], + returns: 'the namespace\'s redacted view under that scope after the write.', throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, { @@ -3311,18 +3318,18 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'settings/document-updated', mode: 'emit', - signature: '\'settings/document-updated\'(ns: SettingsNamespace, revision: number): void', + signature: '\'settings/document-updated\'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void', summary: 'One registered namespace\'s RAW user section changed, whether or not the resolved value did.', description: 'One registered namespace\'s RAW user section changed, whether or not the resolved value did. `settings/updated` is the consumer-facing event and stays deep-equal-gated; this one exists for configuration surfaces, which must learn that a field went from inherited to overridden (same resolved value, different meaning) and that their held revision is stale. Listener containment matches `settings/updated`.', - parameters: [{ name: 'ns', description: 'the namespace whose stored section changed.' }, { name: 'revision', description: 'the namespace\'s new revision.' }], + parameters: [{ name: 'ns', description: 'the namespace whose stored section changed.' }, { name: 'revision', description: 'the section\'s new revision.' }, { name: 'scope', description: 'the named scope whose section changed; absent for the global section.' }], }, { name: 'settings/updated', mode: 'emit', - signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void', + signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void', summary: 'Committed change to one registered namespace\'s resolved value.', description: 'Committed change to one registered namespace\'s resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.', - parameters: [{ name: 'ns', description: 'the namespace whose resolved value changed.' }, { name: 'next', description: 'the new resolved value.' }, { name: 'prev', description: 'the previous resolved value.' }, { name: 'source', description: 'whether the change entered through `update()` or the provider.' }], + parameters: [{ name: 'ns', description: 'the namespace whose resolved value changed.' }, { name: 'next', description: 'the new resolved value.' }, { name: 'prev', description: 'the previous resolved value.' }, { name: 'source', description: 'whether the change entered through `update()` or the provider.' }, { name: 'scope', description: 'the named scope whose registration changed; absent for the global scope.' }], }, { name: 'skills/change', @@ -5450,15 +5457,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsDescribeOptions', - declaration: 'export interface SettingsDescribeOptions {\n redactSecrets?: boolean;\n}', + declaration: 'export interface SettingsDescribeOptions {\n redactSecrets?: boolean;\n scope?: string;\n}', }, { name: 'SettingsDescribeValue', - declaration: 'export interface SettingsDescribeValue {\n writable: boolean;\n hasDocument: boolean;\n namespaces: SettingsNamespaceView[];\n}', + declaration: 'export interface SettingsDescribeValue {\n writable: boolean;\n hasDocument: boolean;\n namespaces: SettingsNamespaceView[];\n scope?: string;\n scopes: string[];\n}', }, { name: 'SettingsDescriptor', - declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', + declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n scope?: SettingsScopeId;\n registered: boolean;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n inherited?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', }, { name: 'SettingsDocumentOpenValue', @@ -5470,7 +5477,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsNamespaceView', - declaration: 'export interface SettingsNamespaceView {\n ns: string;\n schema: JsonValue;\n value: JsonValue;\n base?: JsonValue;\n user?: JsonValue;\n applies: \'live\' | \'restart\';\n secrets: SettingsSecretView[];\n revision: number;\n}', + declaration: 'export interface SettingsNamespaceView {\n ns: string;\n scope?: string;\n registered: boolean;\n inherited?: JsonValue;\n schema: JsonValue;\n value: JsonValue;\n base?: JsonValue;\n user?: JsonValue;\n applies: \'live\' | \'restart\';\n secrets: SettingsSecretView[];\n revision: number;\n}', }, { name: 'SettingsPathOp', @@ -5484,6 +5491,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SettingsRegisterOptions', declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', }, + { + name: 'SettingsScopeId', + declaration: 'export type SettingsScopeId = Branded<\'SettingsScopeId\'>;', + }, { name: 'SettingsSecretView', declaration: 'export interface SettingsSecretView {\n path: string[];\n set: boolean;\n}', diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 06911cb82e..10f5879a2a 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -60,6 +60,19 @@ export { overlayFacts } from './composition-inventory.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' +/** The `dsh-scope` name prefix of a preset's standing scope. */ +export const PRESET_SCOPE_PREFIX = 'preset/' + +/** + * The named-scope id of one preset's standing composition, under which the + * plugins it mounts register their settings. + * @param presetId - the preset id. + * @returns `preset/`. + */ +export function presetScopeId(presetId: string): string { + return `${PRESET_SCOPE_PREFIX}${presetId}` +} + /** Refuse an empty preset id before invoking a domain operation. */ function validatePresetId(value: string, field: 'agentPreset' | 'from'): void { if (value.length === 0) { @@ -823,7 +836,9 @@ export class AgentPresets extends TypertRemoteService { return reused } const key: ScopeKey = { agentPreset: preset.id } - const scope = createScope(this.selfCtx, key) + // Named, so a plugin mounted in the preset registers its settings under + // `preset/` and per-scope state outside the process can address it. + const scope = createScope(this.selfCtx, key, { id: presetScopeId(preset.id) }) try { if (stamp === undefined) { const reason = `composition file is unreadable: ${preset.path}` diff --git a/packages/preset/agent-presets/tests/overlay.spec.ts b/packages/preset/agent-presets/tests/overlay.spec.ts index 38747e219b..9cec776456 100644 --- a/packages/preset/agent-presets/tests/overlay.spec.ts +++ b/packages/preset/agent-presets/tests/overlay.spec.ts @@ -340,3 +340,17 @@ describe('authoring', () => { await expect(ctx.agentPresets.removeOverlay('standard')).rejects.toMatchObject({ code: 'agent-preset/read-only' }) }) }) + +describe('the standing scope', () => { + it('is named after the preset, so plugins it mounts register settings under preset/', async () => { + const { scopeIdOf } = await import('@deepseek-ai/dsh-scope') + const { presetScopeId } = await import('@deepseek-ai/dsh-agent-presets') + const root = await userRoot() + const ctx = await harness(rosterOver(root)) + + const agent = await agentOn(ctx, 'sess-named-scope', 'standard') + + expect(presetScopeId('standard')).toBe('preset/standard') + expect(scopeIdOf(agent.ctx)).toBe('preset/standard') + }) +}) diff --git a/packages/settings/settings-file/README.i18n.yaml b/packages/settings/settings-file/README.i18n.yaml index 9bcf6b5277..65bb42cfc9 100644 --- a/packages/settings/settings-file/README.i18n.yaml +++ b/packages/settings/settings-file/README.i18n.yaml @@ -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/settings/settings-file/README.md -README.md: ea578a1739e1bd5d7b7f47be596998b124337ed5 -README.zh.md: 376d985a454e90402bddd7664e343bfe7df23c7c +README.md: 9e2fbd414f0778b76f4a093a756760ac7cc0328f +README.zh.md: ec708d68e4004af161ef4846a928f44f78b31eb6 diff --git a/packages/settings/settings-file/README.md b/packages/settings/settings-file/README.md index ea578a1739..9e2fbd414f 100644 --- a/packages/settings/settings-file/README.md +++ b/packages/settings/settings-file/README.md @@ -50,7 +50,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Editing the document -The document is a YAML or JSON mapping of namespace to user section. Users can edit it directly: any change takes effect automatically, and deleting the file resets every namespace to defaults and `base`. A document that exists but is invalid fails plugin load at boot — the provider never silently ignores or overwrites it. Once live, an unreadable or unparsable edit warns and keeps the last good sections, so a hand-edit mistake cannot take the process down. +The document is a YAML or JSON mapping of namespace to user section, plus a reserved `scopes` map holding each named scope's own sections — `scopes.preset/standard.skill-filesystem` is the section the `standard` preset resolves over the global `skill-filesystem` one. Users can edit it directly: any change takes effect automatically, and deleting the file resets every namespace to defaults and `base`. A document that exists but is invalid fails plugin load at boot — the provider never silently ignores or overwrites it. Once live, an unreadable or unparsable edit warns and keeps the last good sections, so a hand-edit mistake cannot take the process down. ### Writing through the service diff --git a/packages/settings/settings-file/README.zh.md b/packages/settings/settings-file/README.zh.md index 376d985a45..ec708d68e4 100644 --- a/packages/settings/settings-file/README.zh.md +++ b/packages/settings/settings-file/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 编辑文档 -文档是 namespace 到用户分节的 YAML 或 JSON 映射。用户可以直接编辑:任何变更都会自动生效,删除文件则让所有 namespace 回到默认值与 `base`。存在但非法的文档在启动时使插件加载失败——提供方绝不会静默忽略或覆盖它。运行中不可读或不可解析的编辑只告警并保留最后可用分节,因此手改出错不会拖垮进程。 +文档是 namespace 到用户分节的 YAML 或 JSON 映射,外加一个保留的 `scopes` 映射,存放每个具名 scope 自己的分节——`scopes.preset/standard.skill-filesystem` 就是 `standard` preset 叠加在全局 `skill-filesystem` 分节之上解析的那一段。用户可以直接编辑:任何变更都会自动生效,删除文件则让所有 namespace 回到默认值与 `base`。存在但非法的文档在启动时使插件加载失败——提供方绝不会静默忽略或覆盖它。运行中不可读或不可解析的编辑只告警并保留最后可用分节,因此手改出错不会拖垮进程。 ### 经服务写入 diff --git a/packages/settings/settings-file/src/index.ts b/packages/settings/settings-file/src/index.ts index 6a281479a1..e7cdb31252 100644 --- a/packages/settings/settings-file/src/index.ts +++ b/packages/settings/settings-file/src/index.ts @@ -15,7 +15,7 @@ import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsProvider, type SettingsNamespace, type SettingsScopeId } from '@deepseek-ai/dsh-settings' import { deepEqualJson } from '@deepseek-ai/dsh-util-values' /** Plugin config: file location and hot-reload behavior. */ @@ -92,6 +92,21 @@ function patchNode(document: Document, path: readonly string[], current: unknown if (!deepEqualJson(current, next)) document.setIn([...path], next) } +/** The value at `path` inside a parsed document, or undefined when any step is not a map. */ +function valueAt(root: unknown, path: readonly string[]): unknown { + let current: unknown = root + for (const key of path) { + if (!isMapLike(current)) return undefined + current = current[key] + } + return current +} + +/** One section nested under its path, for a document that has nothing yet. */ +function nestAt(path: readonly string[], section: Record): Record { + return path.reduceRight>((inner, key) => ({ [key]: inner }), section) +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' @@ -182,12 +197,12 @@ export class FileSettingsProvider extends SettingsProvider { return doc } - protected persist(ns: SettingsNamespace, section: Record): Promise { + protected persist(ns: SettingsNamespace, section: Record, scope: SettingsScopeId | undefined): Promise { // One document backs every namespace, so writes from different namespace // queues serialize with each other and with watcher reloads on the one // operation chain: each render must see the text the previous operation // committed, or a sibling section silently vanishes from disk. - return this.enqueue(() => this.persistSection(ns, section)) + return this.enqueue(() => this.persistSection(scope === undefined ? [ns] : ['scopes', scope, ns], section)) } /** Queue one exclusive document operation behind every earlier one. */ @@ -208,7 +223,11 @@ export class FileSettingsProvider extends SettingsProvider { }) } - private async persistSection(ns: SettingsNamespace, section: Record): Promise { + /** + * Write one section at `path` — `[ns]` for a global section, `['scopes', + * scope, ns]` for a scoped one — into the document on disk. + */ + private async persistSection(path: readonly string[], section: Record): Promise { // The writer lock's exclusive create needs the parent to exist before // writeFileAtomic gets its own chance to create it. // 0700: the harness home holds user-private documents. @@ -222,8 +241,8 @@ export class FileSettingsProvider extends SettingsProvider { // a user's manual edit. await this.reconcileFromDisk() const output = this.spec.format === 'yaml' - ? this.renderYaml(ns, section) - : this.renderJson(ns, section) + ? this.renderYaml(path, section) + : this.renderJson(path, section) // 0600: a document that may hold personal values is never world-readable. await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 }) this.text = output @@ -339,31 +358,38 @@ export class FileSettingsProvider extends SettingsProvider { } /** - * Render the next YAML text by patching one namespace in the + * Render the next YAML text by patching one section in the * comment-preserving document. The next section lands as a leaf-level diff * against the stored one — only changed values set, only removed keys * delete — so comments inside the section survive edits to their siblings, - * not just comments outside it. + * not just comments outside it. A scoped section's `scopes.` map is + * created on the way when the document has none. */ - private renderYaml(ns: SettingsNamespace, section: Record): string { + private renderYaml(path: readonly string[], section: Record): string { if (this.text === undefined) { - return new Document({ [ns]: section }).toString() + return new Document(nestAt(path, section)).toString() } // this.text only ever caches content that parsed successfully, so this // re-parse (for the mutable comment-preserving tree) cannot fail, and // parse() already rejected any non-map root. const document = parseDocument(this.text) - const root: unknown = document.toJS() - patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section) + patchNode(document, path, valueAt(document.toJS(), path), section) return document.toString() } - /** Render the next JSON text by replacing one namespace key. */ - private renderJson(ns: SettingsNamespace, section: Record): string { + /** Render the next JSON text by replacing one section at its path. */ + private renderJson(path: readonly string[], section: Record): string { const root = this.text === undefined ? {} : this.parse(this.text) - root[ns] = section + let container: Record = root + for (const key of path.slice(0, -1)) { + const child = container[key] + const next = isMapLike(child) ? child : {} + container[key] = next + container = next + } + container[path[path.length - 1] as string] = section return `${JSON.stringify(root, null, 2)}\n` } } diff --git a/packages/settings/settings-file/tests/scopes.spec.ts b/packages/settings/settings-file/tests/scopes.spec.ts new file mode 100644 index 0000000000..95c9e93886 --- /dev/null +++ b/packages/settings/settings-file/tests/scopes.spec.ts @@ -0,0 +1,106 @@ +/** + * The document layout of scoped sections: `scopes..`, + * written as the same comment-preserving leaf diff a global section gets, + * in YAML and in JSON, and read back the same way. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { FileSettingsProvider } from '../src/index.ts' + +const ThemeSchema = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-scopes-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(path: string): Promise { + const ctx = new Context() + const fiber = ctx.plugin(FileSettingsProvider, { path, watch: false }) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + ctx.settings.register('ui-theme', ThemeSchema) + return ctx +} + +describe('scoped sections on disk', () => { + it('writes a scoped section under scopes.., creating the map, and keeps comments elsewhere', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, '# my settings\nui-theme:\n fontSize: 18 # big\n') + const ctx = await boot(path) + + await ctx.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard') + await ctx.settings.update('ui-theme', { fontSize: 9 }, undefined, 'preset/standard') + + expect(await readFile(path, 'utf8')).toBe([ + '# my settings', + 'ui-theme:', + ' fontSize: 18 # big', + 'scopes:', + ' preset/standard:', + ' ui-theme:', + ' theme: light', + ' fontSize: 9', + '', + ].join('\n')) + expect(ctx.settings.describe({ scope: 'preset/standard' })[0]).toMatchObject({ + value: { theme: 'light', fontSize: 9 }, + user: { theme: 'light', fontSize: 9 }, + inherited: { theme: 'dark', fontSize: 18 }, + }) + }) + + it('starts an absent document with the scoped section alone', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot(path) + + await ctx.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard') + + expect(await readFile(path, 'utf8')).toBe('scopes:\n preset/standard:\n ui-theme:\n theme: light\n') + }) + + it('reads a scoped section back on the next boot', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const first = await boot(path) + await first.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard') + await first.settings.update('ui-theme', { fontSize: 20 }) + + const second = await boot(path) + + expect(second.settings.describe({ scope: 'preset/standard' })[0]?.value).toEqual({ theme: 'light', fontSize: 20 }) + expect(second.settings.describe()[0]?.value).toEqual({ theme: 'dark', fontSize: 20 }) + }) + + it('writes scoped sections into a JSON document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } })) + const ctx = await boot(path) + + await ctx.settings.update('ui-theme', { theme: 'light' }, undefined, 'preset/standard') + await ctx.settings.update('ui-theme', { fontSize: 9 }, undefined, 'preset/research') + + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ + 'ui-theme': { fontSize: 18 }, + scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } }, 'preset/research': { 'ui-theme': { fontSize: 9 } } }, + }) + }) +}) diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index eadd3679cc..91238160cb 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/README.i18n.yaml @@ -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/settings/settings/README.md -README.md: 5c7313ac5015f64cca6b3d91ee75d44f27ab356c -README.zh.md: 337effed483a1bf28d42b76e95c69bc01f805667 +README.md: 2b09635ba1862b825f89c3cdc7818cdc9720dbc8 +README.zh.md: 02f282d8627f887e08ed597370c5af831bf609f2 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index 5c7313ac50..2b09635ba1 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -55,25 +55,27 @@ const theme = scope.get() // deep-frozen resolved snapshot scope.update({ density: 'compact' }) // merges into the user section and persists ``` -Literal namespace arguments are checked by TypeScript against the lowercase letter, digit, and hyphen grammar; dynamically supplied strings receive the same validation at runtime. `ctx.settings.installSection(owner, ns, schema, entry, hooks)` packages the optional-service wiring for a consumer plugin: while a settings service exists it registers the namespace with the plugin's composition entry as `base`; when the service goes away the plugin falls back to its entry config and keeps working exactly as composed. +Literal namespace arguments are checked by TypeScript against the lowercase letter, digit, and hyphen grammar; dynamically supplied strings receive the same validation at runtime, and `scopes` is reserved for the document's per-scope sections. `ctx.settings.installSection(owner, ns, schema, entry, hooks)` packages the optional-service wiring for a consumer plugin: while a settings service exists it registers the namespace with the plugin's composition entry as `base`; when the service goes away the plugin falls back to its entry config and keeps working exactly as composed. + +A namespace is one kind of setting, and a registration is one instance of it under a scope. The instance registers under the caller's nearest named `dsh-scope` scope — a plugin mounted inside an agent preset registers under `preset/`, a host row under the global scope — and resolves the document's global section with that scope's own section (`scopes.preset/.`) layered over it. Two presets mounting the same plugin therefore hold two instances of one kind, each with its own composition `base`; every registrant of a namespace must carry the same schema envelope, and a second registration under the same scope fails loud. ### Reading and observing values -`get(ns)` returns the resolved value as a deep-frozen snapshot, `undefined` while the namespace is unregistered. `watch(callback)` invokes the callback after each committed change with `(next, prev)`: invocations of one callback run one at a time in commit order, and failures are contained and logged, so a slow or throwing observer never blocks or breaks other observers. +`get(ns, scope?)` returns the resolved value of one instance as a deep-frozen snapshot — the global instance when no scope is named — and `undefined` while the namespace is unregistered under that scope. `watch(callback)` invokes the callback after each committed change with `(next, prev)`: invocations of one callback run one at a time in commit order, and failures are contained and logged, so a slow or throwing observer never blocks or breaks other observers. ### Writing values -`update(ns, patch)` deep-merges a plain-object patch into the user section only — never into `base` — validates the resolved candidate, persists through the provider, then commits. `replace(ns, section)` sets the user section wholesale, which is the removal/reset path: `replace({})` re-inherits `base` and schema defaults. `mutate(ns, ops)` applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue — the removal path for a caller holding an incomplete (for example redacted) view, because rebuilding a section from what a wire surface returned and replacing it wholesale would delete every field the wire never sent back. +`update(ns, patch)` deep-merges a plain-object patch into the user section only — never into `base` — validates the resolved candidate, persists through the provider, then commits. Every write verb takes an optional trailing `scope`: a scoped write edits `scopes..` and commits that scope's instance alone, a global write edits the top-level section and re-resolves every instance of the kind, each gated on its own resolved value so a scope that overrides the changed field is not disturbed. A scope nothing has registered yet still accepts a write when the namespace is registered somewhere — the section is judged by the shared schema and takes effect when an owner mounts there. The owner handle's `update`/`replace` write the handle's own scope. `replace(ns, section)` sets the user section wholesale, which is the removal/reset path: `replace({})` re-inherits `base` and schema defaults. `mutate(ns, ops)` applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue — the removal path for a caller holding an incomplete (for example redacted) view, because rebuilding a section from what a wire surface returned and replacing it wholesale would delete every field the wire never sent back. Every write rejects non-JSON-compatible data (a `Date`, `Map`, `BigInt`, non-finite number, or circular reference fails with its `$`-rooted path before anything persists), rejects on a read-only provider, and accepts an optional `expectedRevision`: pass back the `revision` from a descriptor, and a namespace that moved past it refuses the write with `SettingsConflictError` instead of overwriting the writer that landed first. ### Configuration surfaces -`describe()` returns one descriptor per registered namespace: the serialized schema, the resolved value, the detached `base` and `user` layers (a field's presence in `user` marks it user-overridden), the effect timing, and the namespace's revision. Pass `redactSecrets: true` on every wire surface: it strips `role('secret')` fields from every layer and enumerates them as `{ path, set }` slots so a page can render write-only inputs without ever receiving a secret. `documentPath` and `prepareDocument()` expose the provider's user-editable file to a native editor when one exists. +`describe()` returns one descriptor per namespace kind under the global scope, and `describe({ scope })` one per kind under a named scope: the serialized schema, the resolved value, the detached `base` and `user` layers (a field's presence in `user` marks it user-overridden; for a scoped descriptor `user` is the scope's own section), the effect timing, the section's revision, `registered` (whether an owner registered the namespace under that scope — a scope no session composed yet is described from the kind alone, without a `base`), and for a scoped descriptor `inherited`, the value the scope resolves without its own section. `scopes()` lists every named scope some namespace is registered under. Pass `redactSecrets: true` on every wire surface: it strips `role('secret')` fields from every layer and enumerates them as `{ path, set }` slots so a page can render write-only inputs without ever receiving a secret. `documentPath` and `prepareDocument()` expose the provider's user-editable file to a native editor when one exists. ### Events and failures -`settings/updated (ns, next, prev, source)` fires after each committed change — an in-process write (`source: 'update'`) or an externally observed edit (`source: 'provider'`) — and never when the resolved value is deep-equal. `settings/document-updated (ns, revision)` fires whenever the raw user section changed, even when the resolved value did not, which is what an open editor needs to learn that a field went from inherited to overridden. A stored section the schema rejects keeps the namespace's last good value and warns on reload; at registration the same failure rejects the registration itself. +`settings/updated (ns, next, prev, source, scope?)` fires after each committed change of one instance — an in-process write (`source: 'update'`) or an externally observed edit (`source: 'provider'`) — and never when the resolved value is deep-equal; `scope` is absent for the global instance. `settings/document-updated (ns, revision, scope?)` fires whenever one raw section changed, even when the resolved value did not, which is what an open editor needs to learn that a field went from inherited to overridden; every section, global or scoped, registered or not yet, is versioned on its own. A stored section the schema rejects keeps the namespace's last good value and warns on reload; at registration the same failure rejects the registration itself. ----- @@ -97,14 +99,14 @@ This section explains the design decisions behind the service and points at the | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Service Definition: namespace validation, registration, resolution, write queue, describe/redaction, events, `installSection` | +| [`src/index.ts`](src/index.ts) | Service Definition: namespace and scope validation, kind and instance registration, scoped resolution, per-section write queues and revisions, describe/redaction, events, `installSection` | | [`src/redact.ts`](src/redact.ts) | `redactSecrets` walker: strip `role('secret')` fields and enumerate their slots | -| [`src/types.ts`](src/types.ts) | Client-safe type surface: event declarations, `SettingsNamespace`, `SettingsUpdateSource` | +| [`src/types.ts`](src/types.ts) | Client-safe type surface: event declarations, `SettingsNamespace`, `SettingsScopeId`, `SettingsUpdateSource`, the wire views | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: `settings/updated` fires only for a registered namespace, only on a resolved-value change, with the authoritative value | ### Resolution and write paths -Each write snapshots its input at call time (detaching and validating JSON-shaped data), then queues on the namespace's serialized chain. At the front of the queue the service re-reads the section as it stands, checks `expectedRevision`, merges/replaces/mutates, resolves and validates the candidate through the schema plus the owner's optional `validate`, persists through the provider, and only then commits and emits. A write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody; teardown refuses new writes and drains queued writes and started watcher invocations before disposal completes. +Each write snapshots its input at call time (detaching and validating JSON-shaped data), then queues on the section's serialized chain — one chain per namespace and scope. At the front of the queue the service re-reads the section as it stands, checks `expectedRevision`, merges/replaces/mutates, resolves and validates the candidate for every instance the section feeds (all of the kind for the global section, one for a scoped one) through the schema plus the kind's `validate`, persists through the provider with the scope, and only then commits and emits. Resolution layers schema defaults, the instance's `base`, the global section, and the instance's scoped section, in that order. A write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody; teardown refuses new writes and drains queued writes and started watcher invocations before disposal completes. ### Change detection and events @@ -146,7 +148,8 @@ No direct invalidation; a consumer that folds a settings value into the request These limits define when the service is a poor fit or needs special care. They are current package constraints, not a task backlog. -- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; it does not record which layer supplied each resolved value. +- **Two user layers, no provenance per field** — resolution knows schema defaults, one composition `base` per instance, the global section, and one scoped section; a descriptor's `user` and `inherited` let a surface tell an override from an inherited value, but the service does not record which layer supplied each resolved field. +- **The kind's `validate` and `applies` come from the first registrant** — a later instance's differing check or effect timing is ignored, because every instance is the same plugin. - **`redactSecrets` is not a proven wire boundary** — the walker follows `object`/`dict`/`array` containers, so a `role('secret')` field reachable only through a union, intersection, or transform is returned verbatim with an empty `secrets` list, and the serialized schema carries a secret field's default to every client. Neither case is rejected; a schema whose secrets are not reachable through the walked containers must not be registered on a wire-exposed namespace. A fail-closed `describeForWire()` — one that refuses a schema it cannot prove safe and sanitizes the serialized envelope and error text — is the deferred answer. - **Cross-process concurrency is provider-defined** — the service serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins). diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 337effed48..02f282d862 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -55,25 +55,27 @@ const theme = scope.get() // deep-frozen resolved snapshot scope.update({ density: 'compact' }) // merges into the user section and persists ``` -TypeScript 会按小写字母、数字与连字符文法检查字面量 namespace 参数;运行时动态传入的字符串接受相同校验。`ctx.settings.installSection(owner, ns, schema, entry, hooks)` 为消费方插件封装可选服务接线:只要设置服务存在,它就用插件的组合配置作为 `base` 注册 namespace;服务消失时插件回退到组合配置,行为与原先完全一致。 +TypeScript 会按小写字母、数字与连字符文法检查字面量 namespace 参数;运行时动态传入的字符串接受相同校验,`scopes` 保留给文档的按 scope 分节。`ctx.settings.installSection(owner, ns, schema, entry, hooks)` 为消费方插件封装可选服务接线:只要设置服务存在,它就用插件的组合配置作为 `base` 注册 namespace;服务消失时插件回退到组合配置,行为与原先完全一致。 + +namespace 是一种设置的 kind,一次注册是它在某个 scope 下的一个 instance。instance 注册在调用者最近的具名 `dsh-scope` 作用域之下——挂在 agent preset 内的插件注册在 `preset/` 下,宿主行注册在全局 scope 下——并以该 scope 自己的分节(`scopes.preset/.`)叠加在文档的全局分节之上来解析。因此挂同一插件的两个 preset 持有同一 kind 的两个 instance,各自带自己的组合 `base`;同一 namespace 的每个注册者必须携带相同的 schema 信封,同一 scope 下的第二次注册大声失败。 ### 读取与观察值 -`get(ns)` 以深冻结快照返回解析值,namespace 未注册时为 `undefined`。`watch(callback)` 在每次已提交变更后以 `(next, prev)` 调用回调:同一回调的调用按提交顺序逐个执行,异常被隔离并记入日志,因此慢或抛错的观察者绝不会阻塞或破坏其他观察者。 +`get(ns, scope?)` 以深冻结快照返回某个 instance 的解析值——未指名 scope 时为全局 instance——该 scope 下 namespace 未注册时为 `undefined`。`watch(callback)` 在每次已提交变更后以 `(next, prev)` 调用回调:同一回调的调用按提交顺序逐个执行,异常被隔离并记入日志,因此慢或抛错的观察者绝不会阻塞或破坏其他观察者。 ### 写入值 -`update(ns, patch)` 把普通对象 patch 深合并进用户分节——绝不进 `base`——校验解析候选值、经提供方持久化后提交。`replace(ns, section)` 整体替换用户分节,是删除/重置路径:`replace({})` 重新继承 `base` 与 schema 默认值。`mutate(ns, ops)` 在写入排到队首那一刻的分节上按序施加 `{ op: 'set' | 'unset', path }` 编辑——这是持有不完整(例如脱敏后)视图的调用方的删除路径,因为按协议接口返回的内容重建分节再整体替换,会删掉协议从未回传的每个字段。 +`update(ns, patch)` 把普通对象 patch 深合并进用户分节——绝不进 `base`——校验解析候选值、经提供方持久化后提交。每个写入动词都接受可选的尾随 `scope`:scoped 写入编辑 `scopes..` 并只提交该 scope 的 instance,全局写入编辑顶层分节并重新解析该 kind 的每个 instance,各自按自身解析值门控,因此覆盖了被改字段的 scope 不会被打扰。尚无注册的 scope 只要该 namespace 在别处已注册就仍接受写入——分节由共享 schema 判定,并在有 owner 挂到那里时生效。owner 句柄的 `update`/`replace` 写入句柄自己的 scope。`replace(ns, section)` 整体替换用户分节,是删除/重置路径:`replace({})` 重新继承 `base` 与 schema 默认值。`mutate(ns, ops)` 在写入排到队首那一刻的分节上按序施加 `{ op: 'set' | 'unset', path }` 编辑——这是持有不完整(例如脱敏后)视图的调用方的删除路径,因为按协议接口返回的内容重建分节再整体替换,会删掉协议从未回传的每个字段。 每次写入都会拒绝与 JSON 不兼容的数据(`Date`、`Map`、`BigInt`、非有限数或循环引用会在任何内容持久化前以 `$` 为根的路径报错)、拒绝只读提供方上的写入,并可接受可选的 `expectedRevision`:把 descriptor 中的 `revision` 传回,namespace 已越过该值时写入会被 `SettingsConflictError` 拒绝,而不是覆盖先完成写入的一方。 ### 配置界面 -`describe()` 为每个已注册 namespace 返回一条 descriptor:序列化 schema、解析值、分离的 `base` 与 `user` 层(字段出现在 `user` 中即标记为用户覆盖)、生效时机与 namespace 的 revision。每个协议接口都必须传入 `redactSecrets: true`:它从每一层剥离 `role('secret')` 字段,并把它们枚举为 `{ path, set }` slot,让页面可以渲染只写输入而不接触任何机密。`documentPath` 与 `prepareDocument()` 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。 +`describe()` 在全局 scope 下为每个 namespace kind 返回一条 descriptor,`describe({ scope })` 在某个具名 scope 下为每个 kind 返回一条:序列化 schema、解析值、分离的 `base` 与 `user` 层(字段出现在 `user` 中即标记为用户覆盖;scoped descriptor 的 `user` 是该 scope 自己的分节)、生效时机、该分节的 revision、`registered`(该 scope 下是否有 owner 注册了此 namespace——尚无会话组合过的 scope 仅由 kind 描述,没有 `base`),以及 scoped descriptor 的 `inherited`——该 scope 不带自己分节时解析出的值。`scopes()` 列出所有有 namespace 注册于其下的具名 scope。每个协议接口都必须传入 `redactSecrets: true`:它从每一层剥离 `role('secret')` 字段,并把它们枚举为 `{ path, set }` slot,让页面可以渲染只写输入而不接触任何机密。`documentPath` 与 `prepareDocument()` 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。 ### 事件与失败 -`settings/updated (ns, next, prev, source)` 在每次已提交变更后触发——进程内写入(`source: 'update'`)或外部观察到的编辑(`source: 'provider'`)——解析值深相等时绝不触发。`settings/document-updated (ns, revision)` 在原始用户分节发生变化时触发,即使解析值没有变——已打开的编辑器正需要它来得知字段从继承变为覆盖。schema 拒绝的存量分节在重载时保留该 namespace 的最后可用值并告警;注册时同样的失败会直接拒绝注册。 +`settings/updated (ns, next, prev, source, scope?)` 在某个 instance 每次已提交变更后触发——进程内写入(`source: 'update'`)或外部观察到的编辑(`source: 'provider'`)——解析值深相等时绝不触发;全局 instance 时 `scope` 缺席。`settings/document-updated (ns, revision, scope?)` 在某个原始分节发生变化时触发,即使解析值没有变——已打开的编辑器正需要它来得知字段从继承变为覆盖;每个分节,无论全局或 scoped、已注册或尚未,都各自版本化。schema 拒绝的存量分节在重载时保留该 namespace 的最后可用值并告警;注册时同样的失败会直接拒绝注册。 ----- @@ -97,14 +99,14 @@ TypeScript 会按小写字母、数字与连字符文法检查字面量 namespac | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | Service Definition:namespace 校验、注册、解析、写队列、describe/脱敏、事件、`installSection` | +| [`src/index.ts`](src/index.ts) | Service Definition:namespace 与 scope 校验、kind 与 instance 注册、按 scope 解析、按分节的写队列与 revision、describe/脱敏、事件、`installSection` | | [`src/redact.ts`](src/redact.ts) | `redactSecrets` 遍历器:剥离 `role('secret')` 字段并枚举其 slot | -| [`src/types.ts`](src/types.ts) | 客户端安全类型面:事件声明、`SettingsNamespace`、`SettingsUpdateSource` | +| [`src/types.ts`](src/types.ts) | 客户端安全类型面:事件声明、`SettingsNamespace`、`SettingsScopeId`、`SettingsUpdateSource`、wire 视图 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:`settings/updated` 只对已注册 namespace、只在解析值变化时、且携带权威值触发 | ### 解析与写入路径 -每次写入都在调用时对输入做快照(分离并校验 JSON 形状的数据),然后排上该 namespace 的串行链。在队首,服务按当前状态重读分节、检查 `expectedRevision`、合并/替换/编辑、经 schema 与 owner 的可选 `validate` 解析并校验候选值、经提供方持久化,然后才提交并发出事件。registrant fiber 在写入途中被 dispose 的写入仍到达存储,但不会提交、也不会通知任何人;卸载先拒绝新写入,并排干排队写入与已启动的 watcher 调用后才完成。 +每次写入都在调用时对输入做快照(分离并校验 JSON 形状的数据),然后排上该分节的串行链——每个 namespace 与 scope 各一条。在队首,服务按当前状态重读分节、检查 `expectedRevision`、合并/替换/编辑、为该分节供给的每个 instance(全局分节是该 kind 的全部,scoped 分节是那一个)经 schema 与 kind 的 `validate` 解析并校验候选值、带着 scope 经提供方持久化,然后才提交并发出事件。解析按顺序叠加 schema 默认值、instance 的 `base`、全局分节与 instance 的 scoped 分节。registrant fiber 在写入途中被 dispose 的写入仍到达存储,但不会提交、也不会通知任何人;卸载先拒绝新写入,并排干排队写入与已启动的 watcher 调用后才完成。 ### 变更检测与事件 @@ -146,7 +148,8 @@ TypeScript 会按小写字母、数字与连字符文法检查字面量 namespac 这些限制说明本服务何时不合适或需要特别注意。它们是当前包约束,不是任务积压。 -- **单一用户层**——解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;它不记录每个解析值由哪一层提供。 +- **两个用户层,无逐字段来源**——解析只认识 schema 默认值、每个 instance 一个组合 `base`、全局分节与一个 scoped 分节;descriptor 的 `user` 与 `inherited` 让界面分辨覆盖与继承,但服务不记录每个解析字段由哪一层提供。 +- **kind 的 `validate` 与 `applies` 取自第一个注册者**——后来 instance 不同的检查或生效时机被忽略,因为每个 instance 都是同一个插件。 - **`redactSecrets` 并非一条可被证明的协议边界**——遍历器只跟随 `object`/`dict`/`array` 容器,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 字段会被原样返回,且 `secrets` 列表为空;序列化 schema 还会把 secret 字段的默认值带给每个客户端。两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。fail-closed 的 `describeForWire()`——拒绝自己无法证明安全的 schema,并对序列化封装与错误文本做净化——是暂缓的答案。 - **跨进程并发由提供方定义**——服务仅在进程内按 namespace 串行写入;跨进程并发按提供方行为收敛(文件提供方在写锁下读-改-写,因此并发写入者不会丢掉彼此的 namespace,同 namespace 冲突按后写胜出解决)。 diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 00da4957cf..fa9e49263d 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -40,6 +40,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, @@ -47,6 +48,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 232364b4e8..5efc723cdc 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -2,22 +2,38 @@ * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of * per-namespace sections; plugins register a namespace schema and read the * resolved value, which layers schema defaults, the registrant's composition - * `base`, and the user document section, in that order. + * `base`, the user document's global section, and — for a registrant inside + * a named scope — that scope's own section, in that order. + * + * A namespace is one KIND of setting: every registrant of a namespace shares + * its schema, and each registers one INSTANCE under the scope its context + * belongs to (`dsh-scope`'s nearest named scope, or the global scope when + * none is named). Two agent presets mounting the same plugin therefore hold + * two instances of one kind, each resolving the document's global section + * plus its own `scopes..` section. * @module @deepseek-ai/dsh-settings */ import { Context, Service } from '@deepseek-ai/cordis' import type z from '@deepseek-ai/schemastery' +import { scopeIdOf } from '@deepseek-ai/dsh-scope' import { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values' import { redactSecrets } from './redact.ts' import type { RedactedSecret } from './redact.ts' -import type { SettingsNamespace, SettingsUpdateSource } from './types.ts' +import type { SettingsNamespace, SettingsScopeId, SettingsUpdateSource } from './types.ts' export { redactSecrets } from './redact.ts' export type { RedactedSecret, RedactedValue } from './redact.ts' -export type { SettingsNamespace, SettingsUpdateSource } from './types.ts' +export type { SettingsNamespace, SettingsScopeId, SettingsUpdateSource } from './types.ts' const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ +/** + * Top-level document keys that are not namespaces. `scopes` holds the + * per-scope sections, so a namespace of that name would collide with them. + */ +const RESERVED_NAMESPACES: ReadonlySet = new Set(['scopes']) +/** The grammar of a scope id as a document key: `preset/standard`, `preset/my-preset`. */ +const SCOPE_PATTERN = /^[a-z][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*$/ type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' type DecimalDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' @@ -39,9 +55,31 @@ function parseSettingsNamespace(value: string): SettingsNamespace { if (!NAMESPACE_PATTERN.test(value)) { throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`) } + if (RESERVED_NAMESPACES.has(value)) { + throw new TypeError(`settings namespace "${value}" is reserved for the document's per-scope sections`) + } return value as SettingsNamespace } +/** + * Validate one scope id at the seam boundary. + * @param value - the id a caller or a named scope supplied. + * @returns the branded id. + * @throws {TypeError} when the id is not a `/`-separated lowercase path. + */ +export function parseSettingsScopeId(value: string): SettingsScopeId { + if (!SCOPE_PATTERN.test(value)) { + throw new TypeError(`settings scope "${value}" must match ${String(SCOPE_PATTERN)}`) + } + return value as SettingsScopeId +} + +/** The named scope of a registrant's context, validated for use as a document key. */ +function scopeOfContext(ctx: Context): SettingsScopeId | undefined { + const id = scopeIdOf(ctx) + return id === undefined ? undefined : parseSettingsScopeId(id) +} + /** When a namespace's changes take effect for its owner. */ export type SettingsApplies = 'live' | 'restart' @@ -68,6 +106,9 @@ export interface SettingsRegisterOptions { * registration there is no last good value yet, so a stored section that * already fails rejects the registration itself — again exactly as a schema * failure does. + * + * The check belongs to the namespace kind: the first registrant's check + * judges every instance, because every instance is the same plugin. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void @@ -79,6 +120,14 @@ export interface SettingsDescriptor { // public API, provider contract, implementations, tests, and consumers. /** The registered namespace. */ ns: SettingsNamespace + /** The named scope the descriptor resolves under; absent for the global scope. */ + scope?: SettingsScopeId + /** + * Whether an owner registered the namespace under this scope. False for a + * scope described from the kind alone — a preset no session composed yet — + * whose value then carries no composition `base`. + */ + registered: boolean /** Serialized schemastery schema (`schema.toJSON()`). */ schema: unknown /** Current resolved value. */ @@ -93,8 +142,15 @@ export interface SettingsDescriptor { /** * Raw user section from the stored document (detached), when one exists and * is well-formed; a field's presence here is what marks it user-overridden. + * For a scoped descriptor this is the scope's own section, not the global one. */ user?: unknown + /** + * For a scoped descriptor: the value the scope resolves without its own + * user section — defaults, base, and the global section — so a surface can + * tell a field the scope overrides from one it inherits. + */ + inherited?: unknown /** Owner's declared effect timing. */ applies: SettingsApplies /** Schema-declared secret positions; present only under `redactSecrets`. */ @@ -109,11 +165,17 @@ export interface SettingsDescribeOptions { * the verbatim default exists for same-process configuration UIs only. */ redactSecrets?: boolean + /** + * Describe every namespace kind under this named scope instead of the + * global scope. A kind with no registration under the scope is described + * from the kind alone, `registered: false`. + */ + scope?: string } /** Owner-facing handle for one registered namespace. */ export interface SettingsScope { - /** Current resolved value: schema defaults, then `base`, then the user layer. */ + /** Current resolved value: schema defaults, then `base`, then the user layers. */ get(): T /** * Observe committed changes to this namespace's resolved value. Invocations @@ -126,14 +188,15 @@ export interface SettingsScope { */ watch(callback: (next: T, prev: T) => void | Promise): () => void /** - * Merge a partial patch into this namespace's user layer and persist it. + * Merge a partial patch into this registration's user section — the scope's + * own section for a scoped registration — and persist it. * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** - * Replace this namespace's user section wholesale; absent keys re-inherit - * the composition `base` and schema defaults (`replace({})` resets all). + * Replace this registration's user section wholesale; absent keys re-inherit + * the layers below (`replace({})` resets the section). * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ @@ -303,27 +366,37 @@ interface SettingsWatcher { active: boolean } -/** One live namespace registration owned by a registrant fiber. */ -interface SettingsRegistration { +/** + * One namespace kind: the schema every registrant shares, and the instances + * registered under each scope. The kind exists while at least one instance + * does. + */ +interface SettingsKind { ns: SettingsNamespace schema: z - base: unknown + /** `schema.toJSON()`, the envelope a later registrant must match. */ + schemaJson: unknown applies: SettingsApplies - /** Owner-supplied check for constraints the schema cannot express. */ + /** The first registrant's check, judging every instance. */ validate?: (value: unknown) => void + /** Instances by scope id; the global scope is the empty key. */ + instances: Map +} + +/** One live registration of a namespace under one scope, owned by a registrant fiber. */ +interface SettingsRegistration { + kind: SettingsKind + scope: SettingsScopeId | undefined + base: unknown resolved: unknown - /** - * Monotonic counter over this namespace's RAW user section — bumped by any - * change to what is stored, including one whose resolved value is - * unchanged (adding an override equal to the composition base). Editors - * carry it as `expectedRevision` to detect a concurrent write, and the - * document event carries it so another tab learns a field went from - * inherited to overridden. - */ - revision: number watchers: Set } +/** The map key of one instance: its scope id, or the empty key for the global scope. */ +function instanceKey(scope: SettingsScopeId | undefined): string { + return scope ?? '' +} + /** * Abstract settings service. Providers implement raw-document storage * (`load`/`persist`) and push external changes through {@link Settings.publish}; @@ -331,11 +404,21 @@ interface SettingsRegistration { * detection, and the `settings/updated` commit event. */ export abstract class SettingsProvider extends Service { - private readonly registrations = new Map() + private readonly kinds = new Map() /** Latest published raw document; empty until the provider's first publish. */ private document: Record = {} - /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */ - private readonly writeQueues = new Map>() + /** + * Monotonic counter per stored section (namespace and scope) — bumped by + * any change to what is stored, including one whose resolved value is + * unchanged (adding an override equal to the composition base). Editors + * carry it as `expectedRevision` to detect a concurrent write, and the + * document event carries it so another tab learns a field went from + * inherited to overridden. Kept off the registrations so a section written + * for a scope nothing has registered yet is versioned the same way. + */ + private readonly revisions = new Map() + /** Per-section write chains; settled tails, so a failure never poisons the queue. */ + private readonly writeQueues = new Map>() /** In-flight watcher invocation segments, drained by the dispose teardown. */ private readonly pendingTails = new Set>() /** Set at service dispose: refuse new writes while queued ones drain. */ @@ -393,28 +476,39 @@ export abstract class SettingsProvider extends Service { } /** - * Read the provider's current raw document (namespace to raw section). + * Read the provider's current raw document (namespace to raw section, with + * per-scope sections under `scopes..`). * @returns the detached raw document. */ protected abstract load(): Promise> /** - * Durably store one namespace's merged user section. + * Durably store one section's merged user content. * @param ns - the namespace being written. * @param section - the complete merged user section to store. + * @param scope - the named scope whose section is written; `undefined` for + * the global section. A provider that predates scopes and ignores the + * argument stores every write in the global section. */ - protected abstract persist(ns: SettingsNamespace, section: Record): Promise + protected abstract persist(ns: SettingsNamespace, section: Record, scope: SettingsScopeId | undefined): Promise /** * Register a namespace schema and receive its owner scope. The registration * is an effect on the calling plugin's fiber: disposing that fiber removes - * the namespace and its observers. An invalid stored section fails the + * the instance and its observers. An invalid stored section fails the * registration itself — the earliest point where the schema can judge it. - * @param ns - unique namespace; duplicate registration fails loud. + * + * The instance registers under the caller's nearest named scope: a plugin + * mounted inside an agent preset resolves that preset's section over the + * global one, and two presets mounting the same plugin hold two instances + * of one kind. A second registrant of a namespace must carry the same + * schema envelope; a different one is a different setting under a taken + * name and fails loud. + * @param ns - the namespace; a second registration under the same scope fails loud. * @param schema - schemastery schema resolving this namespace's value. * @param options - composition `base` layer and effect timing. * @returns the owner scope for reads, observation, and updates. - * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. + * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved. */ register( ns: Namespace & SettingsNamespaceInput, @@ -422,27 +516,48 @@ export abstract class SettingsProvider extends Service { options?: SettingsRegisterOptions, ): SettingsScope { const parsedNs = parseSettingsNamespace(ns) - if (this.registrations.has(parsedNs)) { - throw new Error(`settings namespace "${parsedNs}" is already registered`) + const scope = scopeOfContext(this.ctx) + const key = instanceKey(scope) + const schemaJson: unknown = schema.toJSON() + const existing = this.kinds.get(parsedNs) + if (existing?.instances.has(key) === true) { + throw new Error(`settings namespace "${parsedNs}" is already registered${scope === undefined ? '' : ` under scope "${scope}"`}`) } - const registration: SettingsRegistration = { + if (existing !== undefined && !deepEqualJson(existing.schemaJson, schemaJson)) { + throw new Error( + `settings namespace "${parsedNs}" is already registered with a different schema; ` + + 'a namespace is one kind of setting, so every registrant of it must share the schema', + ) + } + const kind: SettingsKind = existing ?? { ns: parsedNs, schema: schema as z, - base: options?.base, + schemaJson, applies: options?.applies ?? 'live', ...options?.validate === undefined ? {} : { validate: options.validate as (value: unknown) => void }, - resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)), - revision: 0, + instances: new Map(), + } + const registration: SettingsRegistration = { + kind, + scope, + base: options?.base, + resolved: deepFreeze(this.resolve(kind.schema, options?.base, this.userLayer(parsedNs, scope), kind.validate)), watchers: new Set(), } this.ctx.effect(() => { - this.registrations.set(parsedNs, registration) + this.kinds.set(parsedNs, kind) + kind.instances.set(key, registration) // TODO(settings-registration-quiescence): Deactivate every watcher and await // its tail on disposal so callbacks cannot outlive the registrant fiber. - return () => this.registrations.delete(parsedNs) - }, `settings.register(${JSON.stringify(String(parsedNs))})`) + return () => { + // The kind lives while an instance does: a later registrant of the + // namespace finds this kind until its last instance is gone. + kind.instances.delete(key) + if (kind.instances.size === 0) this.kinds.delete(parsedNs) + } + }, `settings.register(${JSON.stringify(String(parsedNs))}${scope === undefined ? '' : `, ${JSON.stringify(String(scope))}`})`) return { get: () => registration.resolved as T, watch: (callback) => { @@ -453,8 +568,9 @@ export abstract class SettingsProvider extends Service { registration.watchers.delete(watcher) } }, - update: patch => this.update(parsedNs, patch), - replace: section => this.replace(parsedNs, section), + // Through the async verbs, so a refused input rejects instead of throwing. + update: patch => this.update(parsedNs, patch, undefined, scope), + replace: section => this.replace(parsedNs, section, undefined, scope), } } @@ -496,113 +612,160 @@ export abstract class SettingsProvider extends Service { } /** - * Describe every registered namespace for configuration surfaces, including - * the composition `base` and raw user layers so a form can mark which fields - * the user overrode (presence in `user`) and what a reset returns to. - * @param options - redaction switch; wire surfaces must redact. - * @returns one descriptor per registered namespace, in registration order. + * Describe every namespace kind for configuration surfaces, under the + * global scope or one named scope: the composition `base` and raw user + * layers so a form can mark which fields the user overrode (presence in + * `user`) and what a reset returns to, and for a scoped read the + * `inherited` value the scope's own section is layered over. A kind with + * no instance under the requested scope is described from the kind alone. + * @param options - redaction switch (wire surfaces must redact) and scope. + * @returns one descriptor per namespace kind, in registration order. + * @throws {TypeError} when `scope` is not a well-formed scope id. */ describe(options?: SettingsDescribeOptions): SettingsDescriptor[] { - return [...this.registrations.values()].map((registration) => { - let user: Record | undefined - try { - user = this.section(registration.ns) - } catch { - // A malformed stored section already warned at publish and kept the - // last good resolved value; only that malformed shape can throw here, - // and describing it as "no user layer" keeps this read total. - user = undefined + const scope = options?.scope === undefined ? undefined : parseSettingsScopeId(options.scope) + return [...this.kinds.values()].map(kind => this.describeKind(kind, scope, options?.redactSecrets === true)) + } + + /** + * Every named scope some namespace is registered under, in first-seen order. + * @returns the scope ids. + */ + scopes(): SettingsScopeId[] { + const found = new Set() + for (const kind of this.kinds.values()) { + for (const instance of kind.instances.values()) { + if (instance.scope !== undefined) found.add(instance.scope) } - const base = registration.base === undefined ? undefined : structuredClone(registration.base) - const detachedUser = user === undefined ? undefined : structuredClone(user) - const descriptor: SettingsDescriptor = { - ns: registration.ns, - schema: registration.schema.toJSON(), - value: registration.resolved, - revision: registration.revision, - ...base === undefined ? {} : { base }, - ...detachedUser === undefined ? {} : { user: detachedUser }, - applies: registration.applies, - } - if (options?.redactSecrets !== true) return descriptor - const schema = registration.schema as z - const redacted = redactSecrets(schema, registration.resolved) - return { - ...descriptor, - value: redacted.value, - ...base === undefined ? {} : { base: redactSecrets(schema, base).value }, - ...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value }, - secrets: redacted.secrets, - } - }) + } + return [...found] + } + + /** One kind's descriptor under one scope. */ + private describeKind(kind: SettingsKind, scope: SettingsScopeId | undefined, redact: boolean): SettingsDescriptor { + const registration = kind.instances.get(instanceKey(scope)) + const user = this.sectionOrUndefined(kind.ns, scope) + // An unregistered scope resolves from the kind alone; a stored section + // the schema refuses, or a malformed one, is described as the layers + // below it, so the read stays total the way a registered kind's last + // good value keeps it. + const value = registration?.resolved ?? this.resolveSafely(kind, undefined, this.userLayerOrUndefined(kind.ns, scope)) + const inherited = scope === undefined + ? undefined + : this.resolveSafely(kind, registration?.base, this.userLayerOrUndefined(kind.ns, undefined)) + const base = registration?.base === undefined ? undefined : structuredClone(registration.base) + const detachedUser = user === undefined ? undefined : structuredClone(user) + const descriptor: SettingsDescriptor = { + ns: kind.ns, + ...scope === undefined ? {} : { scope }, + registered: registration !== undefined, + schema: kind.schemaJson, + value, + revision: this.revisionOf(kind.ns, scope), + ...base === undefined ? {} : { base }, + ...detachedUser === undefined ? {} : { user: detachedUser }, + ...inherited === undefined ? {} : { inherited }, + applies: kind.applies, + } + if (!redact) return descriptor + const schema = kind.schema as z + const redacted = redactSecrets(schema, value) + return { + ...descriptor, + value: redacted.value, + ...base === undefined ? {} : { base: redactSecrets(schema, base).value }, + ...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value }, + ...inherited === undefined ? {} : { inherited: redactSecrets(schema, inherited).value }, + secrets: redacted.secrets, + } + } + + /** Resolve through the kind, falling back to the layers below a section the schema refuses. */ + private resolveSafely(kind: SettingsKind, base: unknown, layer: unknown): unknown { + try { + return deepFreeze(this.resolve(kind.schema, base, layer, kind.validate)) + } catch { + // The stored section is malformed for this schema; `publish` already + // warned. Describing the layers beneath it keeps the read total. + return deepFreeze(this.resolve(kind.schema, base, undefined, undefined)) + } } /** * Read one registered namespace's resolved value. * @param ns - the namespace to read. - * @returns the resolved value, or `undefined` while unregistered. + * @param scope - the named scope of the instance; the global instance when omitted. + * @returns the resolved value, or `undefined` while unregistered under that scope. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ - get(ns: Namespace & SettingsNamespaceInput): unknown { - return this.registrations.get(parseSettingsNamespace(ns))?.resolved + get(ns: Namespace & SettingsNamespaceInput, scope?: string): unknown { + const parsedScope = scope === undefined ? undefined : parseSettingsScopeId(scope) + return this.kinds.get(parseSettingsNamespace(ns))?.instances.get(instanceKey(parsedScope))?.resolved } /** - * Merge a patch into one registered namespace's user layer, validate the - * resolved candidate, persist through the provider, then commit and emit. - * A validation failure rejects before anything is persisted. Writes to one - * namespace are serialized: concurrent updates apply in call order, each - * merging over the previous write's committed section. + * Merge a patch into one namespace's user section, validate the resolved + * candidates, persist through the provider, then commit and emit. A + * validation failure rejects before anything is persisted. Writes to one + * section are serialized: concurrent updates apply in call order, each + * merging over the previous write's committed section. A global write + * re-resolves every instance of the kind; a scoped write only that scope's. * @param ns - the registered namespace to update. * @param patch - plain-object patch over the user section. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ async update( ns: Namespace & SettingsNamespaceInput, patch: object, expectedRevision?: number, + scope?: string, ): Promise { - return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision) + return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision, scope === undefined ? undefined : parseSettingsScopeId(scope)) } /** - * Replace one registered namespace's user section wholesale, validate, - * persist, then commit and emit. Keys absent from `section` fall back to the - * composition `base` and schema defaults — this is the removal/reset path a - * merge-only patch cannot express (`replace({})` re-inherits everything). + * Replace one namespace's user section wholesale, validate, persist, then + * commit and emit. Keys absent from `section` fall back to the layers + * below — this is the removal/reset path a merge-only patch cannot express + * (`replace({})` re-inherits everything). * @param ns - the registered namespace to replace. * @param section - the complete next user section. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ async replace( ns: Namespace & SettingsNamespaceInput, section: object, expectedRevision?: number, + scope?: string, ): Promise { - return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision) + return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision, scope === undefined ? undefined : parseSettingsScopeId(scope)) } /** - * Apply path-addressed edits to one registered namespace's user section, - * validate, persist, then commit and emit. The ops are applied to the - * section as it stands when the write reaches the front of the queue, so a - * caller never has to restate fields it did not touch — and, crucially, - * cannot delete fields it never saw. This is the write path for any caller - * holding a redacted view; `replace` remains the wholesale reset. + * Apply path-addressed edits to one namespace's user section, validate, + * persist, then commit and emit. The ops are applied to the section as it + * stands when the write reaches the front of the queue, so a caller never + * has to restate fields it did not touch — and, crucially, cannot delete + * fields it never saw. This is the write path for any caller holding a + * redacted view; `replace` remains the wholesale reset. * @param ns - the registered namespace to edit. * @param ops - ordered path edits; later ops observe earlier ones. * @param expectedRevision - the descriptor `revision` the caller read; a - * namespace that moved past it rejects with {@link SettingsConflictError}. + * section that moved past it rejects with {@link SettingsConflictError}. + * @param scope - the named scope whose section to write; the global section when omitted. * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier. */ async mutate( ns: Namespace & SettingsNamespaceInput, ops: readonly SettingsPathOp[], expectedRevision?: number, + scope?: string, ): Promise { const parsedNs = parseSettingsNamespace(ns) if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`) @@ -614,19 +777,20 @@ export abstract class SettingsProvider extends Service { throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`) } } - return this.write(parsedNs, ops, 'mutate', expectedRevision) + return this.write(parsedNs, ops, 'mutate', expectedRevision, scope === undefined ? undefined : parseSettingsScopeId(scope)) } - /** Validate a write, then queue it on the namespace's serialized write chain. */ + /** Validate a write, then queue it on the section's serialized write chain. */ private write( ns: SettingsNamespace, input: object, mode: 'merge' | 'replace' | 'mutate', - expectedRevision?: number, + expectedRevision: number | undefined, + scope: SettingsScopeId | undefined, ): Promise { const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate' - const registration = this.registrations.get(ns) - if (registration === undefined) { + const kind = this.kinds.get(ns) + if (kind === undefined) { throw new Error(`settings namespace "${ns}" is not registered`) } if (this.isStopped()) { @@ -649,51 +813,93 @@ export abstract class SettingsProvider extends Service { // walk rejects values that JSON cannot preserve (see cloneJsonShaped). const snapshot = cloneJsonShaped(payload, (label, path) => new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`)) - const previous = this.writeQueues.get(ns) ?? Promise.resolve() + const queueKey = this.sectionKey(ns, scope) + const previous = this.writeQueues.get(queueKey) ?? Promise.resolve() // Chain past a failed predecessor: one rejected write must not poison the - // namespace queue for every later caller. + // section queue for every later caller. const run = previous.catch(() => undefined).then(async () => { if (this.isStopped()) { throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`) } - if (this.registrations.get(ns) !== registration) { + if (this.kinds.get(ns) !== kind) { throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`) } // Every mode derives from the section as it stands NOW, at the front of // the queue — never from whatever the caller last saw. - const current = this.section(ns) ?? {} + const current = this.section(ns, scope) ?? {} // The revision check belongs HERE, not at call time: the queue orders // writes but cannot tell a fresh writer from one holding a snapshot // that a predecessor already superseded. - if (expectedRevision !== undefined && expectedRevision !== registration.revision) { - throw new SettingsConflictError(ns, expectedRevision, registration.revision) + const revision = this.revisionOf(ns, scope) + if (expectedRevision !== undefined && expectedRevision !== revision) { + throw new SettingsConflictError(ns, expectedRevision, revision) } const section = mode === 'merge' ? mergeLayers(current, snapshot) as Record : mode === 'replace' ? snapshot : (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current) - const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate)) - await this.persist(ns, section) + // Validate before persisting: every instance the section feeds resolves + // the candidate, and a scope nothing registered yet is judged by the + // schema alone so a malformed section never reaches the document. + const affected = this.affectedInstances(kind, scope) + const candidates = new Map() + for (const instance of affected) { + const layer = this.candidateLayer(ns, instance.scope, scope, section) + candidates.set(instance, deepFreeze(this.resolve(kind.schema, instance.base, layer, kind.validate))) + } + if (affected.length === 0) { + this.resolve(kind.schema, undefined, this.candidateLayer(ns, scope, scope, section), kind.validate) + } + await this.persist(ns, section, scope) // The write reached storage either way; the cache must say so. Commit - // only when this registration is still the namespace owner — a fiber - // disposed (or replaced) mid-persist must not receive the notification. - this.document[ns] = section + // only when this kind is still the namespace owner — a fiber disposed + // (or replaced) mid-persist must not receive the notification. + this.setSection(ns, scope, section) // TODO(settings-replacement-resync): Re-resolve any replacement registration // from this persisted section so an old in-flight write cannot leave it stale. - if (this.registrations.get(ns) === registration && !this.isStopped()) { - this.bumpRevision(registration, current, section) - this.commit(registration, next, 'update') + if (this.kinds.get(ns) === kind && !this.isStopped()) { + this.bumpRevision(ns, scope, current, section) + for (const [instance, next] of candidates) { + // An instance disposed while the write persisted must not receive + // the notification; the kind itself is still the owner (checked above). + /* v8 ignore next */ + if (kind.instances.get(instanceKey(instance.scope)) === instance) this.commit(instance, next, 'update') + } } }) - this.writeQueues.set(ns, run) + this.writeQueues.set(queueKey, run) return run } + /** The instances a write to one section feeds: every instance for the global section, one for a scoped one. */ + private affectedInstances(kind: SettingsKind, scope: SettingsScopeId | undefined): SettingsRegistration[] { + if (scope === undefined) return [...kind.instances.values()] + const instance = kind.instances.get(instanceKey(scope)) + return instance === undefined ? [] : [instance] + } + + /** + * The user layer one instance would resolve if `candidate` replaced the + * section at `writtenScope`: the global section, then the instance's own + * scoped section, with the written one substituted. + */ + private candidateLayer( + ns: SettingsNamespace, + instanceScope: SettingsScopeId | undefined, + writtenScope: SettingsScopeId | undefined, + candidate: Record, + ): unknown { + const global = writtenScope === undefined ? candidate : this.section(ns, undefined) + if (instanceScope === undefined) return global + const scoped = writtenScope === instanceScope ? candidate : this.section(ns, instanceScope) + return mergeLayers(global, scoped) + } + /** * Provider hook: commit a complete raw document observed in storage. Each - * registered namespace re-resolves; an invalid section keeps that - * namespace's last good value and warns, other namespaces still commit. + * registered instance re-resolves; an invalid section keeps that instance's + * last good value and warns, other instances still commit. * @param doc - the detached raw document (unregistered sections preserved). * @param source - change origin; defaults to `provider`. */ @@ -701,51 +907,149 @@ export abstract class SettingsProvider extends Service { // Read every raw section BEFORE swapping the document, so the revision // bump below compares what was stored with what now is — an external edit // moves the revision exactly like an in-process write. - const before = new Map() - for (const registration of this.registrations.values()) { - try { - before.set(registration.ns, this.section(registration.ns)) - } catch { - // A malformed stored section is not a readable "before"; treating it - // as absent still bumps against any well-formed replacement. - before.set(registration.ns, undefined) - } - } + const keys = this.sectionKeys(doc) + const before = new Map() + for (const [key, ns, scope] of keys) before.set(key, this.sectionOrUndefined(ns, scope)) this.document = doc - for (const registration of this.registrations.values()) { - let next: unknown - try { - next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate)) - } catch (error) { - this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) - this.ctx.logger.warn(error) - continue + for (const kind of this.kinds.values()) { + for (const instance of kind.instances.values()) { + let next: unknown + try { + next = deepFreeze(this.resolve(kind.schema, instance.base, this.userLayer(kind.ns, instance.scope), kind.validate)) + } catch (error) { + this.ctx.logger.warn( + 'settings: keeping last good "%s"%s after invalid stored section', + kind.ns, + instance.scope === undefined ? '' : ` under scope "${instance.scope}"`, + ) + this.ctx.logger.warn(error) + continue + } + this.commit(instance, next, source) } - this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns)) - this.commit(registration, next, source) } + for (const [key, ns, scope] of keys) this.bumpRevision(ns, scope, before.get(key), this.sectionOrUndefined(ns, scope)) } - /** Read one namespace's raw user section, rejecting non-object sections. */ - private section(ns: SettingsNamespace): Record | undefined { - const section = this.document[ns] + /** + * Every section a publish must version: each kind's global section and + * every scoped section an instance registered or either document holds. + */ + private sectionKeys(next: Record): [string, SettingsNamespace, SettingsScopeId | undefined][] { + const scopeIds = new Set() + for (const document of [this.document, next]) { + const scopes = document['scopes'] + if (isPlainObject(scopes)) for (const id of Object.keys(scopes)) scopeIds.add(id) + } + const found: [string, SettingsNamespace, SettingsScopeId | undefined][] = [] + for (const kind of this.kinds.values()) { + const ids = new Set(scopeIds) + for (const instance of kind.instances.values()) if (instance.scope !== undefined) ids.add(instance.scope) + found.push([this.sectionKey(kind.ns, undefined), kind.ns, undefined]) + for (const id of ids) { + // A malformed scope id in the document names no section this seam + // reads; it stays where it is and versions nothing. + if (!SCOPE_PATTERN.test(id)) continue + const scope = id as SettingsScopeId + found.push([this.sectionKey(kind.ns, scope), kind.ns, scope]) + } + } + return found + } + + /** The stored `scopes` map, rejecting a non-object value. */ + private scopesContainer(): Record | undefined { + const scopes = this.document['scopes'] + if (scopes === undefined) return undefined + if (!isPlainObject(scopes)) { + throw new TypeError('settings section "scopes" must be an object of scope ids') + } + return scopes + } + + /** Read one raw user section, rejecting non-object sections. */ + private section(ns: SettingsNamespace, scope: SettingsScopeId | undefined): Record | undefined { + let container: Record | undefined = this.document + if (scope !== undefined) { + const entry = this.scopesContainer()?.[scope] + if (entry === undefined) return undefined + if (!isPlainObject(entry)) { + throw new TypeError(`settings scope "${scope}" must be an object of namespace sections`) + } + container = entry + } + const section = container[ns] if (section === undefined) return undefined if (!isPlainObject(section)) { - throw new TypeError(`settings section "${ns}" must be an object of keys`) + throw new TypeError(`settings section "${ns}"${scope === undefined ? '' : ` under scope "${scope}"`} must be an object of keys`) } return section } - /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ + /** {@link section} for readers that treat a malformed section as absent. */ + private sectionOrUndefined(ns: SettingsNamespace, scope: SettingsScopeId | undefined): Record | undefined { + try { + return this.section(ns, scope) + } catch { + // A malformed stored section already warned at publish and kept the + // last good resolved value; only that malformed shape can throw here, + // and reading it as "no user layer" keeps the caller total. + return undefined + } + } + + /** + * The user layer one instance resolves: the global section for the global + * instance, the global section with the scope's own section layered over + * it for a scoped one. + */ + private userLayer(ns: SettingsNamespace, scope: SettingsScopeId | undefined): unknown { + const global = this.section(ns, undefined) + if (scope === undefined) return global + return mergeLayers(global, this.section(ns, scope)) + } + + /** {@link userLayer} for readers that treat a malformed section as absent. */ + private userLayerOrUndefined(ns: SettingsNamespace, scope: SettingsScopeId | undefined): unknown { + const global = this.sectionOrUndefined(ns, undefined) + if (scope === undefined) return global + return mergeLayers(global, this.sectionOrUndefined(ns, scope)) + } + + /** Store one section in the cached document, creating the scope entry it lives in. */ + private setSection(ns: SettingsNamespace, scope: SettingsScopeId | undefined, section: Record): void { + if (scope === undefined) { + this.document[ns] = section + return + } + const scopes = this.scopesContainer() ?? {} + this.document['scopes'] = scopes + const entry = scopes[scope] + const container = isPlainObject(entry) ? entry : {} + scopes[scope] = container + container[ns] = section + } + + /** The revision map key of one section. */ + private sectionKey(ns: SettingsNamespace, scope: SettingsScopeId | undefined): string { + return scope === undefined ? ns : `${ns}@${scope}` + } + + /** The current revision of one section; zero until it first changes. */ + private revisionOf(ns: SettingsNamespace, scope: SettingsScopeId | undefined): number { + return this.revisions.get(this.sectionKey(ns, scope)) ?? 0 + } + + /** Resolve one instance value: schema defaults, then `base`, then the user layer. */ private resolve( schema: z, base: unknown, - section: Record | undefined, + layer: unknown, validate?: (value: T) => void, ): T { // The merged candidate is untyped by construction; the schema call is the // runtime validation that admits it into T. - const value = schema(mergeLayers(base, section) as never) + const value = schema(mergeLayers(base, layer) as never) // The owner's own check runs on the admitted value, so it sees defaults // and the composition base exactly as the owner will. validate?.(value) @@ -753,25 +1057,28 @@ export abstract class SettingsProvider extends Service { } /** - * Advance a namespace's revision when its RAW section changed, and announce + * Advance a section's revision when its RAW content changed, and announce * it. Deliberately independent of {@link commit}'s resolved-value equality: * storing an override equal to the composition base leaves the resolved * value alone but changes what the document says, which is exactly what a * configuration surface must re-read. */ - private bumpRevision(registration: SettingsRegistration, before: unknown, after: unknown): void { + private bumpRevision(ns: SettingsNamespace, scope: SettingsScopeId | undefined, before: unknown, after: unknown): void { if (deepEqualJson(before, after)) return - registration.revision += 1 - this.emitDocumentUpdated(registration.ns, registration.revision) + const key = this.sectionKey(ns, scope) + const revision = (this.revisions.get(key) ?? 0) + 1 + this.revisions.set(key, revision) + this.emitDocumentUpdated(ns, revision, scope) } /** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */ - private emitDocumentUpdated(ns: SettingsNamespace, revision: number): void { + private emitDocumentUpdated(ns: SettingsNamespace, revision: number, scope: SettingsScopeId | undefined): void { let invariantFailure: unknown - const args = ['settings/document-updated', ns, revision] + const payload: unknown[] = scope === undefined ? [ns, revision] : [ns, revision, scope] + const args = ['settings/document-updated', ...payload] for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { try { - const returned = listener(ns, revision) + const returned = listener(...payload) if (returned != null && typeof (returned as PromiseLike).then === 'function') { void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { this.warnListenerFailure(ns, error) @@ -793,6 +1100,7 @@ export abstract class SettingsProvider extends Service { const prev = registration.resolved if (deepEqualJson(next, prev)) return registration.resolved = next + const { ns } = registration.kind for (const watcher of [...registration.watchers]) { // Serialize per watcher: invocations of one callback run one at a time // in commit order, so a slow stale invocation can never apply after a @@ -806,7 +1114,7 @@ export abstract class SettingsProvider extends Service { return watcher.callback(next as never, prev as never) }) .then(() => undefined, (error: unknown) => { - this.warnWatcherFailure(registration.ns, error) + this.warnWatcherFailure(ns, error) }) watcher.tail = segment this.pendingTails.add(segment) @@ -818,16 +1126,19 @@ export abstract class SettingsProvider extends Service { // failure is contained so one broken observer cannot wedge the commit // path (and, through it, a provider's reload loop). let invariantFailure: unknown - const args = ['settings/updated', registration.ns, next, prev, source] + const payload: unknown[] = registration.scope === undefined + ? [ns, next, prev, source] + : [ns, next, prev, source, registration.scope] + const args = ['settings/updated', ...payload] for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { try { - const returned = listener(registration.ns, next, prev, source) + const returned = listener(...payload) if (returned != null && typeof (returned as PromiseLike).then === 'function') { // An emit listener may still be an async function; its rejection // cannot reach the synchronous INVARIANT rethrow below, so it is // contained here instead of becoming an unhandled rejection. void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { - this.warnListenerFailure(registration.ns, error) + this.warnListenerFailure(ns, error) }) } } catch (error) { @@ -835,7 +1146,7 @@ export abstract class SettingsProvider extends Service { invariantFailure ??= error continue } - this.warnListenerFailure(registration.ns, error) + this.warnListenerFailure(ns, error) } } if (invariantFailure !== undefined) throw invariantFailure as Error diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts index 3056671a44..5d962be6f2 100644 --- a/packages/settings/settings/src/invariant.ts +++ b/packages/settings/settings/src/invariant.ts @@ -21,14 +21,14 @@ export const inject = ['invariants'] * seam's own equality predicate. */ const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { - ctx.on('settings/updated', (ns, next, prev) => { + ctx.on('settings/updated', (ns, next, prev, _source, scope) => { const settings = ctx.get('settings') if (settings === undefined) { fail(`settings/updated for "${ns}" emitted without a live settings service`) } - const current = settings.get(ns) + const current = settings.get(ns, scope) if (current === undefined) { - fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`) + fail(`settings/updated for "${ns}" (scope ${String(scope)}) emitted while the namespace is unregistered there`) } if (!deepEqualJson(current, next)) { fail(`settings/updated for "${ns}" does not match the authoritative resolved value`) diff --git a/packages/settings/settings/src/types.ts b/packages/settings/settings/src/types.ts index 8068d4ef13..e283ea446c 100644 --- a/packages/settings/settings/src/types.ts +++ b/packages/settings/settings/src/types.ts @@ -14,6 +14,13 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' /** Nominal id of one registered settings namespace. */ export type SettingsNamespace = Branded<'SettingsNamespace'> +/** + * Nominal id of one named scope a namespace resolves under — the id + * `dsh-scope` gave the scope, such as `preset/standard`. The global scope has + * no id: it is the absence of one. + */ +export type SettingsScopeId = Branded<'SettingsScopeId'> + /** Origin of one committed settings change. */ export type SettingsUpdateSource = 'update' | 'provider' @@ -33,6 +40,21 @@ export interface SettingsSecretView { export interface SettingsNamespaceView { /** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */ ns: string + /** The named scope this view resolves under; absent for the global scope. */ + scope?: string + /** + * Whether an owner registered the namespace under this scope. A scoped view + * of a namespace no owner registered under it — a preset no session has + * composed yet — is described from the namespace kind alone: it has no + * composition `base`, and a write to it takes effect when an owner mounts. + */ + registered: boolean + /** + * For a scoped view: the redacted value the scope resolves without its own + * user section (schema defaults → composition base → global user section), + * so a surface can tell a field the scope overrode from one it inherits. + */ + inherited?: JsonValue /** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */ schema: JsonValue /** Redacted resolved value (schema defaults → composition base → user layer). */ @@ -68,8 +90,16 @@ export interface SettingsDescribeValue { writable: boolean /** Whether a file-backed provider owns a local document, without exposing its Host path. */ hasDocument: boolean - /** One view per registered namespace. */ + /** + * One view per namespace kind under the requested scope: the global scope + * when the read named none, else the named scope, with `registered` saying + * whether an owner registered the namespace there. + */ namespaces: SettingsNamespaceView[] + /** The scope the views resolve under; absent for the global scope. */ + scope?: string + /** Every named scope some namespace is registered under, for a surface offering a scope switch. */ + scopes: string[] } declare module '@deepseek-ai/cordis' { @@ -87,9 +117,10 @@ declare module '@deepseek-ai/cordis' { * @param next - the new resolved value. * @param prev - the previous resolved value. * @param source - whether the change entered through `update()` or the provider. + * @param scope - the named scope whose registration changed; absent for the global scope. * @mode emit */ - 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void + 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void /** * One registered namespace's RAW user section changed, whether or not the @@ -99,9 +130,10 @@ declare module '@deepseek-ai/cordis' { * resolved value, different meaning) and that their held revision is * stale. Listener containment matches `settings/updated`. * @param ns - the namespace whose stored section changed. - * @param revision - the namespace's new revision. + * @param revision - the section's new revision. + * @param scope - the named scope whose section changed; absent for the global section. * @mode emit */ - 'settings/document-updated'(ns: SettingsNamespace, revision: number): void + 'settings/document-updated'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void } } diff --git a/packages/settings/settings/tests/memory.ts b/packages/settings/settings/tests/memory.ts index f4c23f62f5..19b46b2fe8 100644 --- a/packages/settings/settings/tests/memory.ts +++ b/packages/settings/settings/tests/memory.ts @@ -5,14 +5,14 @@ * packages. */ -import { SettingsProvider, type SettingsNamespace } from '../src/index.ts' +import { SettingsProvider, type SettingsNamespace, type SettingsScopeId } from '../src/index.ts' /** In-memory provider exposing the protected provider hooks to tests. */ export class MemorySettings extends SettingsProvider { /** Raw document the provider "storage" currently holds. */ doc: Record - /** Every persist() call observed, in order. */ - persisted: Array<{ ns: SettingsNamespace; section: Record }> = [] + /** Every persist() call observed, in order; `scope` is present only for a scoped section. */ + persisted: Array<{ ns: SettingsNamespace; section: Record; scope?: SettingsScopeId }> = [] /** When false, update() must reject before reaching persist(). */ writableFlag: boolean @@ -38,12 +38,17 @@ export class MemorySettings extends SettingsProvider { return Promise.resolve(structuredClone(this.doc)) } - protected async persist(ns: SettingsNamespace, section: Record): Promise { + protected async persist(ns: SettingsNamespace, section: Record, scope: SettingsScopeId | undefined): Promise { if (this.persistDelayMs > 0) { await new Promise(resolve => setTimeout(resolve, this.persistDelayMs)) } - this.persisted.push({ ns, section: structuredClone(section) }) - this.doc[ns] = structuredClone(section) + this.persisted.push({ ns, section: structuredClone(section), ...scope === undefined ? {} : { scope } }) + if (scope === undefined) { + this.doc[ns] = structuredClone(section) + return + } + const scopes = (this.doc['scopes'] ??= {}) as Record> + ;(scopes[scope] ??= {})[ns] = structuredClone(section) } /** Simulate an external storage change reaching the provider. */ diff --git a/packages/settings/settings/tests/scopes.spec.ts b/packages/settings/settings/tests/scopes.spec.ts new file mode 100644 index 0000000000..af1aa4cbab --- /dev/null +++ b/packages/settings/settings/tests/scopes.spec.ts @@ -0,0 +1,359 @@ +/** + * Namespace kinds and scoped instances: a plugin mounted inside a named scope + * registers its namespace under that scope and resolves the document's global + * section with the scope's own section layered over it; every registrant of + * a namespace shares its schema; the document versions each section on its + * own; and a scope nothing registered yet is still described and writable + * from the kind alone. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import { parseSettingsScopeId, type SettingsScope, type SettingsScopeId, type SettingsUpdateSource } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +const OtherSchema: z<{ theme: string }> = z.object({ + theme: z.string().default('x'), +}) + +interface Booted { + ctx: Context + provider: MemorySettings + updates: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource; scope: SettingsScopeId | undefined }> + documents: Array<{ ns: string; revision: number; scope: SettingsScopeId | undefined }> +} + +async function boot(doc: Record = {}): Promise { + const ctx = new Context() + await ctx.plugin(MemorySettings, { doc }) + const updates: Booted['updates'] = [] + const documents: Booted['documents'] = [] + ctx.on('settings/updated', (ns, next, prev, source, scope) => { updates.push({ ns, next, prev, source, scope }) }) + ctx.on('settings/document-updated', (ns, revision, scope) => { documents.push({ ns, revision, scope }) }) + return { ctx, provider: ctx.get('settings') as MemorySettings, updates, documents } +} + +/** Mount a plugin inside a named scope and register the namespace from it. */ +async function registerIn( + ctx: Context, + id: string | undefined, + ns: string, + schema: z = ThemeSchema, + base?: Partial, +): Promise<{ scope: Scope; handle: SettingsScope }> { + let scope!: Scope + let handle!: SettingsScope + await ctx.plugin((host: Context) => { + scope = createScope(host, { id }, id === undefined ? {} : { id }) + }) + await scope.ctx.plugin({ + inject: ['settings'], + apply(inner: Context) { + handle = inner.settings.register(ns as 'ui-theme', schema, base === undefined ? {} : { base }) + }, + }) + return { scope, handle } +} + +describe('scoped registration', () => { + it('registers an instance under the nearest named scope and resolves its section over the global one', async () => { + const { ctx, provider } = await boot({ + 'ui-theme': { fontSize: 18 }, + scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } } }, + }) + const { handle } = await registerIn(ctx, 'preset/standard', 'ui-theme', ThemeSchema, { theme: 'dark', fontSize: 12 }) + + expect(handle.get()).toEqual({ theme: 'light', fontSize: 18 }) + expect(provider.get('ui-theme', 'preset/standard')).toEqual({ theme: 'light', fontSize: 18 }) + expect(provider.get('ui-theme')).toBeUndefined() + expect(provider.scopes()).toEqual(['preset/standard']) + }) + + it('uses the nearest named ancestor when the registrant\'s own scope is unnamed', async () => { + const { ctx, provider } = await boot({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } } } }) + let handle!: SettingsScope + const namedKey = { id: 'named' } + let anonymous!: Scope + await ctx.plugin((host: Context) => { + const named = createScope(host, namedKey, { id: 'preset/standard' }) + anonymous = createScope(named.ctx, { id: 'agent' }, { parent: namedKey }) + }) + await anonymous.ctx.plugin({ + inject: ['settings'], + apply(inner: Context) { handle = inner.settings.register('ui-theme', ThemeSchema) }, + }) + + expect(handle.get()).toEqual({ theme: 'light', fontSize: 14 }) + expect(provider.scopes()).toEqual(['preset/standard']) + }) + + it('lets two scopes register one kind, and refuses a second schema or a duplicate scope', async () => { + const { ctx } = await boot() + const standard = await registerIn(ctx, 'preset/standard', 'ui-theme') + const research = await registerIn(ctx, 'preset/research', 'ui-theme') + ctx.settings.register('ui-theme', ThemeSchema) + + expect(standard.handle.get()).toEqual({ theme: 'dark', fontSize: 14 }) + expect(research.handle.get()).toEqual({ theme: 'dark', fontSize: 14 }) + expect(ctx.settings.scopes()).toEqual(['preset/standard', 'preset/research']) + expect(() => ctx.settings.register('ui-theme', ThemeSchema)) + .toThrow('settings namespace "ui-theme" is already registered') + await expect(registerIn(ctx, 'preset/standard', 'ui-theme')) + .rejects.toThrow('already registered under scope "preset/standard"') + await expect(registerIn(ctx, 'preset/other', 'ui-theme', OtherSchema as unknown as z)) + .rejects.toThrow('already registered with a different schema') + }) + + it('drops the kind with its last instance, not before', async () => { + const { ctx, provider } = await boot() + const { scope } = await registerIn(ctx, 'preset/standard', 'ui-theme') + const research = await registerIn(ctx, 'preset/research', 'ui-theme') + expect(provider.describe().map(descriptor => descriptor.ns)).toEqual(['ui-theme']) + + await scope.dispose() + expect(provider.scopes()).toEqual(['preset/research']) + expect(provider.describe().map(descriptor => descriptor.ns)).toEqual(['ui-theme']) + + await research.scope.dispose() + expect(provider.describe()).toEqual([]) + expect(provider.scopes()).toEqual([]) + }) + + it('rejects a registration whose scoped section is malformed, naming the scope', async () => { + const { ctx } = await boot({ scopes: { 'preset/standard': { 'ui-theme': 'nope' } } }) + await expect(registerIn(ctx, 'preset/standard', 'ui-theme')).rejects.toThrow('settings section "ui-theme" under scope "preset/standard" must be an object of keys') + }) + + it('installs a section under a scope with the consumer\'s own validation', async () => { + const { ctx, provider } = await boot() + let scope!: Scope + await ctx.plugin((host: Context) => { scope = createScope(host, { id: 'standard' }, { id: 'preset/standard' }) }) + const seen: ThemeConfig[] = [] + await scope.ctx.plugin({ + inject: ['settings'], + apply(inner: Context) { + let source: () => ThemeConfig = () => ({ theme: 'dark', fontSize: 1 }) + inner.settings.installSection(inner, 'ui-theme', ThemeSchema, { theme: 'dark', fontSize: 1 }, { + setSource: (current) => { source = current }, + onChange: () => { seen.push(source()) }, + validate: (value) => { if (value.fontSize > 40) throw new Error('too large') }, + }) + }, + }) + + expect(seen).toEqual([{ theme: 'dark', fontSize: 1 }]) + await provider.update('ui-theme', { fontSize: 30 }, undefined, 'preset/standard') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(seen.at(-1)).toEqual({ theme: 'dark', fontSize: 30 }) + await expect(provider.update('ui-theme', { fontSize: 50 }, undefined, 'preset/standard')).rejects.toThrow('too large') + }) + + it('refuses the reserved scopes namespace and a malformed scope id', async () => { + const { ctx, provider } = await boot() + expect(() => ctx.settings.register('scopes', ThemeSchema)).toThrow('reserved for the document\'s per-scope sections') + expect(() => provider.describe({ scope: 'Preset/Standard' })).toThrow('must match') + expect(() => parseSettingsScopeId('preset//x')).toThrow(TypeError) + expect(parseSettingsScopeId('preset/standard')).toBe('preset/standard') + await expect(registerIn(ctx, 'Not Valid', 'ui-theme')).rejects.toThrow('settings scope "Not Valid" must match') + }) +}) + +describe('writes and change propagation', () => { + it('re-resolves every instance on a global write, gated on the resolved value', async () => { + const { ctx, provider, updates, documents } = await boot({ + scopes: { 'preset/research': { 'ui-theme': { fontSize: 20 } } }, + }) + const standard = await registerIn(ctx, 'preset/standard', 'ui-theme') + const research = await registerIn(ctx, 'preset/research', 'ui-theme') + ctx.settings.register('ui-theme', ThemeSchema) + + await provider.update('ui-theme', { fontSize: 16 }) + + expect(standard.handle.get()).toEqual({ theme: 'dark', fontSize: 16 }) + // The research scope overrides the field the global write changed. + expect(research.handle.get()).toEqual({ theme: 'dark', fontSize: 20 }) + expect(provider.get('ui-theme')).toEqual({ theme: 'dark', fontSize: 16 }) + expect(updates.map(update => [update.scope, (update.next as ThemeConfig).fontSize])).toEqual([ + ['preset/standard', 16], [undefined, 16], + ]) + expect(documents).toEqual([{ ns: 'ui-theme', revision: 1, scope: undefined }]) + expect(provider.persisted).toEqual([{ ns: 'ui-theme', section: { fontSize: 16 } }]) + }) + + it('writes one scope\'s section through the owner handle and the provider, committing only that scope', async () => { + const { ctx, provider, updates, documents } = await boot() + const standard = await registerIn(ctx, 'preset/standard', 'ui-theme') + const research = await registerIn(ctx, 'preset/research', 'ui-theme') + + await standard.handle.update({ theme: 'light' }) + await provider.mutate('ui-theme', [{ op: 'set', path: ['fontSize'], value: 9 }], undefined, 'preset/research') + await provider.replace('ui-theme', { fontSize: 10 }, undefined, 'preset/research') + + expect(standard.handle.get()).toEqual({ theme: 'light', fontSize: 14 }) + expect(research.handle.get()).toEqual({ theme: 'dark', fontSize: 10 }) + expect(provider.persisted).toEqual([ + { ns: 'ui-theme', section: { theme: 'light' }, scope: 'preset/standard' }, + { ns: 'ui-theme', section: { fontSize: 9 }, scope: 'preset/research' }, + { ns: 'ui-theme', section: { fontSize: 10 }, scope: 'preset/research' }, + ]) + expect(provider.doc).toEqual({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } }, 'preset/research': { 'ui-theme': { fontSize: 10 } } } }) + expect(updates.map(update => update.scope)).toEqual(['preset/standard', 'preset/research', 'preset/research']) + expect(documents.map(entry => [entry.scope, entry.revision])).toEqual([['preset/standard', 1], ['preset/research', 1], ['preset/research', 2]]) + await standard.handle.replace({}) + expect(standard.handle.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('versions each section on its own and refuses a stale writer per section', async () => { + const { ctx, provider } = await boot() + await registerIn(ctx, 'preset/standard', 'ui-theme') + ctx.settings.register('ui-theme', ThemeSchema) + await provider.update('ui-theme', { fontSize: 16 }) + await provider.update('ui-theme', { fontSize: 17 }) + + const [global, scoped] = [provider.describe(), provider.describe({ scope: 'preset/standard' })] + expect(global[0]?.revision).toBe(2) + expect(scoped[0]?.revision).toBe(0) + await expect(provider.update('ui-theme', { fontSize: 1 }, 1)).rejects.toMatchObject({ code: 'SETTINGS_CONFLICT', expected: 1, actual: 2 }) + await expect(provider.update('ui-theme', { fontSize: 1 }, 0, 'preset/standard')).resolves.toBeUndefined() + await expect(provider.update('ui-theme', { fontSize: 2 }, 0, 'preset/standard')).rejects.toMatchObject({ code: 'SETTINGS_CONFLICT', expected: 0, actual: 1 }) + }) + + it('accepts a write for a scope nothing registered yet, judged by the schema, and takes effect when an owner mounts', async () => { + const { ctx, provider, updates } = await boot() + ctx.settings.register('ui-theme', ThemeSchema) + + await provider.update('ui-theme', { theme: 'light' }, undefined, 'preset/later') + await expect(provider.update('ui-theme', { theme: 'sepia' }, undefined, 'preset/later')).rejects.toThrow() + + expect(provider.doc).toEqual({ scopes: { 'preset/later': { 'ui-theme': { theme: 'light' } } } }) + expect(updates).toEqual([]) + const later = await registerIn(ctx, 'preset/later', 'ui-theme') + expect(later.handle.get()).toEqual({ theme: 'light', fontSize: 14 }) + await expect(provider.update('absent', { theme: 'light' }, undefined, 'preset/later')).rejects.toThrow('is not registered') + }) + + it('rejects a scoped write whose resolved value the owner refuses, before persisting', async () => { + const { ctx, provider } = await boot() + ctx.settings.register('ui-theme', ThemeSchema, { validate: (value) => { if (value.fontSize > 40) throw new Error('too large') } }) + await registerIn(ctx, 'preset/standard', 'ui-theme') + + await expect(provider.update('ui-theme', { fontSize: 99 }, undefined, 'preset/standard')).rejects.toThrow('too large') + expect(provider.persisted).toEqual([]) + }) + + it('publishes external scope edits, keeps the last good value for a malformed scoped section, and versions unregistered scopes', async () => { + const { ctx, provider, updates, documents } = await boot() + const standard = await registerIn(ctx, 'preset/standard', 'ui-theme') + + provider.pushExternal({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } }, 'preset/other': { 'ui-theme': { fontSize: 1 } } } }) + expect(standard.handle.get()).toEqual({ theme: 'light', fontSize: 14 }) + expect(documents.map(entry => [entry.scope, entry.revision])).toEqual([['preset/standard', 1], ['preset/other', 1]]) + expect(updates.map(update => update.scope)).toEqual(['preset/standard']) + + provider.pushExternal({ scopes: { 'preset/standard': { 'ui-theme': { theme: 'sepia' } } } }) + expect(standard.handle.get()).toEqual({ theme: 'light', fontSize: 14 }) + // A malformed scope entry and a malformed scopes map are read as absent by the describers. + provider.pushExternal({ scopes: { 'preset/standard': 'nope' } }) + expect(provider.describe({ scope: 'preset/standard' })[0]).toMatchObject({ value: { theme: 'light', fontSize: 14 } }) + provider.pushExternal({ scopes: 'nope' }) + expect(provider.describe({ scope: 'preset/standard' })[0]?.user).toBeUndefined() + // A scope id the document spells outside the grammar names no section. + provider.pushExternal({ scopes: { 'Not Valid': { 'ui-theme': { theme: 'light' } } } }) + expect(documents.at(-1)?.scope).not.toBe('Not Valid') + }) + + it('serializes the global and one scoped section on separate queues', async () => { + const { ctx, provider } = await boot() + ctx.settings.register('ui-theme', ThemeSchema) + provider.persistDelayMs = 5 + + await Promise.all([ + provider.update('ui-theme', { fontSize: 1 }), + provider.update('ui-theme', { fontSize: 2 }, undefined, 'preset/standard'), + provider.update('ui-theme', { theme: 'light' }), + ]) + + expect(provider.doc).toEqual({ 'ui-theme': { fontSize: 1, theme: 'light' }, scopes: { 'preset/standard': { 'ui-theme': { fontSize: 2 } } } }) + }) +}) + +describe('describe', () => { + it('describes the global scope from the kind when only scoped instances exist', async () => { + const { ctx, provider } = await boot({ 'ui-theme': { fontSize: 18 } }) + await registerIn(ctx, 'preset/standard', 'ui-theme', ThemeSchema, { theme: 'light' }) + + expect(provider.describe()).toEqual([{ + ns: 'ui-theme', + registered: false, + schema: ThemeSchema.toJSON(), + value: { theme: 'dark', fontSize: 18 }, + revision: 0, + user: { fontSize: 18 }, + applies: 'live', + }]) + }) + + it('describes one scope with its base, own section, and inherited value', async () => { + const { ctx, provider } = await boot({ + 'ui-theme': { fontSize: 18 }, + scopes: { 'preset/standard': { 'ui-theme': { theme: 'light' } } }, + }) + await registerIn(ctx, 'preset/standard', 'ui-theme', ThemeSchema, { theme: 'dark', fontSize: 12 }) + + const [standard] = provider.describe({ scope: 'preset/standard' }) + expect(standard).toEqual({ + ns: 'ui-theme', + scope: 'preset/standard', + registered: true, + schema: ThemeSchema.toJSON(), + value: { theme: 'light', fontSize: 18 }, + revision: 0, + base: { theme: 'dark', fontSize: 12 }, + user: { theme: 'light' }, + inherited: { theme: 'dark', fontSize: 18 }, + applies: 'live', + }) + // A scope no owner registered: no base, inherits the global section. + const [other] = provider.describe({ scope: 'preset/other' }) + expect(other).toEqual({ + ns: 'ui-theme', + scope: 'preset/other', + registered: false, + schema: ThemeSchema.toJSON(), + value: { theme: 'dark', fontSize: 18 }, + revision: 0, + inherited: { theme: 'dark', fontSize: 18 }, + applies: 'live', + }) + }) + + it('redacts secrets in the inherited value too', async () => { + const Secret = z.object({ token: z.string().role('secret'), size: z.number().default(1) }) + const { ctx, provider } = await boot({ secretive: { token: 'global-token' } }) + ctx.settings.register('secretive', Secret) + + const [view] = provider.describe({ scope: 'preset/standard', redactSecrets: true }) + expect(view?.inherited).toEqual({ size: 1 }) + expect(view?.secrets).toEqual([{ path: ['token'], set: true }]) + }) + + it('describes a scope whose stored section the schema refuses from the layers below it', async () => { + const { ctx, provider } = await boot({ scopes: { 'preset/standard': { 'ui-theme': { fontSize: 'huge' } } } }) + ctx.settings.register('ui-theme', ThemeSchema) + + const [standard] = provider.describe({ scope: 'preset/standard' }) + expect(standard).toMatchObject({ registered: false, value: { theme: 'dark', fontSize: 14 }, user: { fontSize: 'huge' } }) + }) +}) diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json index c06688eecf..0668acd09c 100644 --- a/packages/settings/settings/tsconfig.json +++ b/packages/settings/settings/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../util/brand" }, + { + "path": "../../core/scope" + }, { "path": "../../util/values" }, diff --git a/packages/skill/skill-filesystem/README.i18n.yaml b/packages/skill/skill-filesystem/README.i18n.yaml index 9940f3d006..013f3a4811 100644 --- a/packages/skill/skill-filesystem/README.i18n.yaml +++ b/packages/skill/skill-filesystem/README.i18n.yaml @@ -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/skill/skill-filesystem/README.md -README.md: 808df61c0fa248ba02b4e2993e6cbe60ede35f7c -README.zh.md: 6e542dee5bb275488cea9dadba10decd6ecb6733 +README.md: dbd36591ed645bedf821c129615c7e61e623a046 +README.zh.md: 5b06cf3f136d7aecf017a716bc8ebc73beb8fc90 diff --git a/packages/skill/skill-filesystem/README.md b/packages/skill/skill-filesystem/README.md index 808df61c0f..dbd36591ed 100644 --- a/packages/skill/skill-filesystem/README.md +++ b/packages/skill/skill-filesystem/README.md @@ -68,12 +68,14 @@ Load the plugin alongside the skill registry; it requires `ctx.skills`. | `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs` | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness config root; its `skills` subdirectory is scanned | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills | -| `customSkillDirs` | `[]` | Additional local skill roots, after project roots and before user roots | +| `customSkillDirs` | `[]` | Additional local skill roots, after project roots and before user roots; also the base of the `skill-filesystem` settings section | | `watch` | `true` | Watch local roots and invalidate the provider when the catalog may have changed | | `bundledSkillDir` | — | Bundled skill root scanned at rank 600 when configured | The remaining `watch*` fields tune Chokidar behavior — polling, stability window, interval, project cap, and symlink following. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-skill-filesystem) is the exhaustive source for every field. +While a settings provider is composed, `customSkillDirs` also resolves through the `skill-filesystem` settings namespace with the composition value as its base: a person adds a root to their own catalog in the settings document, and the provider re-lists with the new roots without a restart. A row mounted inside an agent preset registers under that preset's scope, so `scopes.preset/.skill-filesystem` adds roots for that preset alone over the global section. Without a settings provider the composition value stands alone. + ### Change detection Existing roots are watched, so adding, renaming, or deleting a skill (or editing its frontmatter) triggers a catalog refresh for the next model step; edits below `references`, `scripts`, `assets`, and other bundle resources do not. The first-party `write` and `edit` tools invalidate the provider directly when their target could affect a watched skill, so the model observes its own filesystem mutation without waiting for the host watcher. External IDE, Git, and shell changes are picked up by the host watcher, and a root that does not exist yet is probed until it appears. diff --git a/packages/skill/skill-filesystem/README.zh.md b/packages/skill/skill-filesystem/README.zh.md index 6e542dee5b..5b06cf3f13 100644 --- a/packages/skill/skill-filesystem/README.zh.md +++ b/packages/skill/skill-filesystem/README.zh.md @@ -68,12 +68,14 @@ skill 可以是被扫描根目录顶层的目录 bundle `/SKILL.md`,也 | `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根与用户根 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | Harness 配置根目录;扫描其 `skills` 子目录 | | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent 配置根目录 | -| `customSkillDirs` | `[]` | 其他本地 skill 根目录,位于项目根之后、用户根之前 | +| `customSkillDirs` | `[]` | 其他本地 skill 根目录,位于项目根之后、用户根之前;也是 `skill-filesystem` settings 分节的 base | | `watch` | `true` | 监视本地根,并在目录可能变化时使提供方失效 | | `bundledSkillDir` | — | 配置后按 rank 600 扫描的内置 skill 根目录 | 其余 `watch*` 字段用于调节 Chokidar 行为——轮询、稳定窗口、间隔、项目上限与符号链接跟随。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-skill-filesystem)是每个字段的穷尽式真源。 +组合了 settings provider 时,`customSkillDirs` 还会以组合值为 base 经 `skill-filesystem` settings namespace 解析:一个人在 settings 文档里给自己的目录添加一个根目录,provider 就用新的根目录重新列出,无需重启。挂在 agent preset 内的行注册在该 preset 的 scope 下,因此 `scopes.preset/.skill-filesystem` 只为该 preset 在全局分节之上添加根目录。没有 settings provider 时只有组合值。 + ### 变更检测 现有根目录会被监视,因此新增、改名或删除 skill(或编辑其 frontmatter)会在下一个模型步骤触发目录刷新;`references`、`scripts`、`assets` 等 bundle 资源下的编辑不会触发。当第一方 `write` 与 `edit` 工具的目标可能影响受监视的 skill 时,它们会直接使提供方失效,因此模型无需等待宿主 watcher 即可观察到自身的文件系统变更。外部 IDE、Git 与 shell 变更由宿主 watcher 捕获;尚不存在的根目录会被探测,直至其出现。 diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index fc5dda6a3f..f9f9e78cf8 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -29,9 +29,15 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-settings": { + "optional": true + } + }, "dependencies": { "chokidar": "^5.0.0", "@deepseek-ai/schemastery": "workspace:^", @@ -40,6 +46,7 @@ "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/skill/skill-filesystem/src/index.ts b/packages/skill/skill-filesystem/src/index.ts index 3986a44384..fe0811df36 100644 --- a/packages/skill/skill-filesystem/src/index.ts +++ b/packages/skill/skill-filesystem/src/index.ts @@ -20,6 +20,8 @@ import type Schema from '@deepseek-ai/schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' +// Type-only: resolves `ctx.settings` for the optional settings injection. +import type {} from '@deepseek-ai/dsh-settings' import { BUNDLED_SKILL_RANK, isSkillName, @@ -45,6 +47,23 @@ const DEFAULT_WATCH_MAX_PROJECTS = 128 export const name = 'skill-filesystem' export const inject = ['skills'] +/** Settings namespace carrying the user-writable subset of this provider's config. */ +export const SETTINGS_NAMESPACE = 'skill-filesystem' + +/** + * The user-writable subset: the roots a person adds to their own catalog. + * Every other field is a deployment choice a composition sets. + */ +export interface SkillFilesystemSettings { + /** Additional skill roots scanned after project roots and before user roots. */ + customSkillDirs: string[] +} + +/** Runtime schema for the user-writable subset. */ +export const SkillFilesystemSettingsSchema: z = z.object({ + customSkillDirs: z.array(z.string()).default([]), +}) + /** Local filesystem skill provider configuration. */ export interface Config { /** Unique provider name. Defaults to `filesystem`. */ @@ -126,7 +145,17 @@ interface ResolvedWatchConfig { followSymlinks: boolean } -/** Register the local filesystem skill provider on `ctx.skills`. */ +/** + * Register the local filesystem skill provider on `ctx.skills`. While a + * settings provider is composed, the user-writable subset of the config — + * `customSkillDirs` — resolves through the `skill-filesystem` settings + * namespace with the composition value as its base, under the scope this + * row is mounted in: a row inside an agent preset reads that preset's + * section over the global one. Without a settings provider the composition + * value stands alone. + * @param ctx - the mounting context. + * @param config - the provider config; `customSkillDirs` is the settings base. + */ export function apply(ctx: Context, config: Config = {}): void { let provider!: FileSystemSkillProvider ctx.skills.registerProvider((control) => { @@ -136,6 +165,15 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.effect(function* () { yield async () => { await provider.dispose() } }, 'skill-filesystem watcher') + const entry: SkillFilesystemSettings = { customSkillDirs: config.customSkillDirs ?? [] } + ctx.inject(['settings'], (settingsCtx) => { + // Assigned by `setSource` before the first `onChange`, per the hooks contract. + let source!: () => SkillFilesystemSettings + settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, SkillFilesystemSettingsSchema, entry, { + setSource: (current) => { source = current }, + onChange: () => { provider.setCustomSkillDirs(source().customSkillDirs) }, + }) + }) ctx.on('fs/observed', (target, _observation, actor) => { if (mutationToolName(actor) === undefined) return provider.observeHostMutation(target.displayPath) @@ -148,7 +186,8 @@ export class FileSystemSkillProvider implements SkillProvider { private readonly includeDefaultRoots: boolean private readonly dshHome: string private readonly agentsHome: string - private readonly customSkillDirs: string[] + private customSkillDirs: string[] + private readonly control: SkillProviderControl private readonly watchManager: SkillWatchManager private readonly bundledSkillDir: string | undefined private disposal: Promise | undefined @@ -163,6 +202,7 @@ export class FileSystemSkillProvider implements SkillProvider { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) + this.control = control this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) // The environment bundled root is a default root: an isolated provider @@ -221,6 +261,20 @@ export class FileSystemSkillProvider implements SkillProvider { } } + /** + * Replace the custom roots and invalidate the catalog when they changed — + * the settings path for a person adding a root to their own catalog while + * the process runs. Watching follows on the next listing, which observes + * the roots it scans. + * @param dirs - the roots, relative paths resolved against the process cwd. + */ + setCustomSkillDirs(dirs: readonly string[]): void { + const next = dirs.map(root => resolve(root)) + if (next.length === this.customSkillDirs.length && next.every((root, index) => root === this.customSkillDirs[index])) return + this.customSkillDirs = next + this.control.invalidate() + } + /** * Invalidate this provider synchronously after a first-party filesystem mutation. * @param path - host display path observed after a model-facing write or edit. diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts index 238d1421c7..c2458834b8 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts +++ b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts @@ -880,3 +880,32 @@ describe('FileSystemSkillProvider', () => { } }) }) + +describe('the skill-filesystem settings section', () => { + it('exposes customSkillDirs through settings with the composition value as base, and re-lists on change', async () => { + const { MemorySettings } = await import('../../../settings/settings/tests/memory.ts') + const home = await tempDir('skill-settings') + const composed = join(home, 'composed') + const added = join(home, 'added') + await writeSkill(composed, 'composed-skill', 'From the composition.') + await writeSkill(added, 'added-skill', 'From the user document.') + const ctx = new Context() + await ctx.plugin(MemorySettings) + await ctx.plugin(SkillRegistry) + await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false, includeDefaultRoots: false, customSkillDirs: [composed] }) + + const [descriptor] = ctx.settings.describe() + expect(descriptor).toMatchObject({ ns: 'skill-filesystem', base: { customSkillDirs: [composed] }, value: { customSkillDirs: [composed] } }) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['composed-skill']) + + await ctx.settings.update('skill-filesystem', { customSkillDirs: [added] }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['added-skill']) + + // The same roots again change nothing; a reset restores the composition's. + await ctx.settings.update('skill-filesystem', { customSkillDirs: [added] }) + await ctx.settings.replace('skill-filesystem', {}) + await new Promise(resolve => setTimeout(resolve, 0)) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['composed-skill']) + }) +}) diff --git a/packages/skill/skill-filesystem/tsconfig.json b/packages/skill/skill-filesystem/tsconfig.json index 5962881448..4d4417f82f 100644 --- a/packages/skill/skill-filesystem/tsconfig.json +++ b/packages/skill/skill-filesystem/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../../../vendor/schemastery" }, { "path": "../../fs/fs" }, { "path": "../../util/home-paths" }, - { "path": "../skill" } + { "path": "../skill" }, + { "path": "../../settings/settings" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 462c6cb81a..53927c18cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../packages/fs/fs-local + '@deepseek-ai/dsh-global-tool-mask': + specifier: workspace:^ + version: link:../../packages/preset/global-tool-mask '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -295,9 +298,6 @@ importers: '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-global-tool-mask': - specifier: workspace:^ - version: link:../../packages/preset/global-tool-mask '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -887,6 +887,9 @@ importers: '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -6660,22 +6663,6 @@ importers: specifier: workspace:^ version: link:../../typert/protocol - packages/preset/persona: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - packages/preset/global-tool-mask: dependencies: '@deepseek-ai/schemastery': @@ -6695,6 +6682,22 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/preset/persona: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + packages/runtime-diagnostics/invariants: dependencies: '@deepseek-ai/schemastery': @@ -7625,6 +7628,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -8091,6 +8097,9 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 47fc4414c9..225221c485 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -545,6 +545,7 @@ export const LINK_MAP: Readonly> = { ToolRestriction: 'tools.md', ToolSchema: 'tools.md', SettingsNamespace: 'settings.md', + SettingsScopeId: 'settings.md', SettingsNamespaceInput: 'settings.md', SettingsRegisterOptions: 'settings.md', SettingsSectionHooks: 'settings.md', From 1570eeeaee2676f457314f06f0e1db515a69c2f8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 04:31:59 +0800 Subject: [PATCH 004/147] feat(web): manage plugins and per-preset settings from the browser Add the Manage plugins tab (ui-settings-plugin-manager): package cards with enable/disable switches, retry and uninstall, an install dialog streaming pnpm output, and the selected preset's composition with per-row switches and removal; the read-only list gains provenance facts. Settings cards edit one scope at a time: ui-settings keeps one describe mirror per settings scope (SettingsMirrorRegistry) and binds { namespace, scope }; ui-settings-plugins stages forms per scope, marks inherited fields, and adds the skill-filesystem roots card. Fixes surfaced by driving the browser end to end: the client gateway now lets a call omit trailing parameters that accept undefined (PR3's optional scope arguments failed the strict arity check), the plugins install verb is `add` because the client namespace service reserves `install`/`remove` (guarded by remote-method-names.host.spec), a plugin module offers its main export as an addable `.`, and describing a settings scope no owner registered resolves over the global instance's base and never throws. The web e2e scaffold can mount a profile runtime over fixture packages; plugin-manager.e2e drives the tab and writes one field under a preset scope; plugin-config and settings-chrome goldens are refreshed. --- ...lugin-management-in-web-settings.i18n.yaml | 6 + ...09-04-plugin-management-in-web-settings.md | 37 ++ ...04-plugin-management-in-web-settings.zh.md | 37 ++ ...manager-over-the-profile-runtime.i18n.yaml | 4 +- ...plugin-manager-over-the-profile-runtime.md | 2 +- ...gin-manager-over-the-profile-runtime.zh.md | 2 +- ...ngs-namespaces-resolve-per-scope.i18n.yaml | 4 +- ...4-settings-namespaces-resolve-per-scope.md | 2 +- ...ettings-namespaces-resolve-per-scope.zh.md | 2 +- .../plugin-config/section.expected.md | 6 + .../plugin-manager/manager.expected.md | 127 ++++ .../plugins/fixture-bundle/cordis.patch.yml | 5 + .../fixtures/plugins/fixture-bundle/index.js | 5 + .../plugins/fixture-bundle/package.json | 14 + .../plugins/fixture-plain-plugin/index.js | 5 + .../plugins/fixture-plain-plugin/package.json | 11 + apps/web/tests/plugin-manager.e2e.ts | 189 ++++++ apps/web/tests/scaffold.ts | 53 +- apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 7 +- docs/subsystems/settings.zh.md | 7 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/api/gateway/src/client/index.ts | 16 +- .../api/gateway/tests/gateway.client.spec.ts | 54 ++ .../tests/remote-method-names.host.spec.ts | 49 ++ packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 2 +- .../ui-settings-plugin-inventory/README.zh.md | 2 +- .../src/client/PluginInventorySettingsTab.tsx | 15 +- .../src/client/locales.ts | 12 + .../tests/components.client.spec.tsx | 32 + .../README.i18n.yaml | 6 + .../ui-settings-plugin-manager/README.md | 112 ++++ .../ui-settings-plugin-manager/README.zh.md | 112 ++++ .../ui-settings-plugin-manager/package.json | 73 +++ .../PluginManagerSettingsTab.module.css | 484 ++++++++++++++ .../src/client/PluginManagerSettingsTab.tsx | 615 ++++++++++++++++++ .../src/client/index.ts | 82 +++ .../src/client/locales.ts | 216 ++++++ .../src/client/manager-store.ts | 406 ++++++++++++ .../src/css-modules.d.ts | 6 + .../ui-settings-plugin-manager/src/index.ts | 4 + .../tests/browser-plugin.client.spec.tsx | 98 +++ .../tests/components.client.spec.tsx | 394 +++++++++++ .../tests/manager-store.client.spec.ts | 359 ++++++++++ .../ui-settings-plugin-manager/tsconfig.json | 25 + .../tsdown.config.ts | 3 + .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 8 +- .../client/ui-settings-plugins/README.zh.md | 8 +- .../client/ui-settings-plugins/package.json | 9 +- .../src/client/AgentLoopCard.tsx | 1 + .../src/client/BashCard.tsx | 2 + .../src/client/ConfigurablePluginsTab.tsx | 98 ++- .../src/client/PluginCard.tsx | 3 + .../client/PluginsSettingsSection.module.css | 65 ++ .../src/client/SkillFilesystemCard.tsx | 48 ++ .../src/client/WebSearchCard.tsx | 2 + .../src/client/agent-loop-card-controller.ts | 17 +- .../src/client/bash-card-controller.ts | 17 +- .../src/client/card-form.ts | 90 ++- .../src/client/fields.module.css | 28 + .../ui-settings-plugins/src/client/fields.tsx | 50 +- .../ui-settings-plugins/src/client/index.ts | 55 +- .../ui-settings-plugins/src/client/locales.ts | 34 +- .../src/client/scope-switcher.ts | 168 +++++ .../src/client/scoped-form.ts | 205 ++++++ .../skill-filesystem-card-controller.ts | 58 ++ ...ubagent-model-selection-card-controller.ts | 33 +- .../src/client/web-search-card-controller.ts | 29 +- .../tests/apply.client.spec.ts | 86 ++- .../tests/fields.client.spec.tsx | 2 + .../tests/scope-switcher.client.spec.ts | 116 ++++ .../tests/scoped-form.client.spec.ts | 123 ++++ .../tests/section.client.spec.tsx | 120 +++- .../tests/stores.client.spec.ts | 170 +++-- .../client/ui-settings-plugins/tsconfig.json | 6 + packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 8 +- packages/client/ui-settings/README.zh.md | 8 +- .../client/ui-settings/src/client/index.ts | 23 +- .../src/client/settings-contract.ts | 20 + .../ui-settings/src/client/settings-mirror.ts | 86 ++- .../ui-settings/src/client/settings-scope.ts | 50 +- .../ui-settings/tests/plugin.client.spec.ts | 28 +- .../tests/settings-mirror.client.spec.ts | 76 ++- .../tests/settings-scope.client.spec.ts | 68 +- .../src/client/slot-catalog.ts | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/host/plugin-manager/README.i18n.yaml | 4 +- packages/host/plugin-manager/README.md | 6 +- packages/host/plugin-manager/README.zh.md | 6 +- packages/host/plugin-manager/src/index.ts | 24 +- .../tests/plugin-manager.spec.ts | 25 +- packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 2 +- packages/settings/settings/README.zh.md | 2 +- packages/settings/settings/src/index.ts | 33 +- .../settings/settings/tests/scopes.spec.ts | 41 ++ .../client-runtime/src/settings-scope.ts | 1 + pnpm-lock.yaml | 76 ++- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 122 files changed, 5740 insertions(+), 271 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md create mode 100644 .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md create mode 100644 apps/web/tests/expected/plugin-manager/manager.expected.md create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/index.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/package.json create mode 100644 apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json create mode 100644 apps/web/tests/plugin-manager.e2e.ts create mode 100644 packages/api/remotes/tests/remote-method-names.host.spec.ts create mode 100644 packages/client/ui-settings-plugin-manager/README.i18n.yaml create mode 100644 packages/client/ui-settings-plugin-manager/README.md create mode 100644 packages/client/ui-settings-plugin-manager/README.zh.md create mode 100644 packages/client/ui-settings-plugin-manager/package.json create mode 100644 packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css create mode 100644 packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx create mode 100644 packages/client/ui-settings-plugin-manager/src/client/index.ts create mode 100644 packages/client/ui-settings-plugin-manager/src/client/locales.ts create mode 100644 packages/client/ui-settings-plugin-manager/src/client/manager-store.ts create mode 100644 packages/client/ui-settings-plugin-manager/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-plugin-manager/src/index.ts create mode 100644 packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx create mode 100644 packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx create mode 100644 packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts create mode 100644 packages/client/ui-settings-plugin-manager/tsconfig.json create mode 100644 packages/client/ui-settings-plugin-manager/tsdown.config.ts create mode 100644 packages/client/ui-settings-plugins/src/client/SkillFilesystemCard.tsx create mode 100644 packages/client/ui-settings-plugins/src/client/scope-switcher.ts create mode 100644 packages/client/ui-settings-plugins/src/client/scoped-form.ts create mode 100644 packages/client/ui-settings-plugins/src/client/skill-filesystem-card-controller.ts create mode 100644 packages/client/ui-settings-plugins/tests/scope-switcher.client.spec.ts create mode 100644 packages/client/ui-settings-plugins/tests/scoped-form.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml new file mode 100644 index 0000000000..87f591bbca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-04-plugin-management-in-web-settings.md +2026-09-04-plugin-management-in-web-settings.md: b0ccbe9934ac14ce4ee77b26d2a677f36f623be9 +2026-09-04-plugin-management-in-web-settings.zh.md: 40fc1933deb71331c1bc1c2c16baa90afd097e62 diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md new file mode 100644 index 0000000000..b0ccbe9934 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md @@ -0,0 +1,37 @@ +# Agent Note: Plugin management lands in Web settings + +Status: implemented + +English | [中文](2026-09-04-plugin-management-in-web-settings.zh.md) + +## Problem + +The Host could manage a profile's plugins — install, enable, disable, retry, compose rows into a preset — and resolve a settings namespace per named scope, but the browser exposed none of it. The Plugins section had a configuration tab over the global instance of each namespace and a read-only list; a person who wanted the values of one preset, or a bundle switched on without the CLI, had no surface. The settings client read one `settings.describe` answer and bound every namespace globally, so a per-preset value had no path from the document to a card. + +## Decision + +**One mirror per settings scope.** `ui-settings` keeps a `SettingsMirrorRegistry`: the global mirror as before, and one mirror per named scope created the first time a consumer binds or describes it. A `settings/document-updated` event names the scope it landed in; a global commit reloads every mirror, because a named scope resolves over the global section, and a scoped commit reloads that scope alone. `bind({ namespace, scope })` derives from the scope's mirror and passes the scope on every write; the snapshot carries `scope`, `registered`, and `inherited`. + +**Cards stage per scope, components stay scope-blind.** `ui-settings-plugins` holds one `ScopeSelection` for the configurable tab and a `ScopedCardForms` per card: one `CardForm` per scope, bound lazily, with drafts that belong to the scope they were typed under. The card components read the same hooks and call the same actions, which route to the selected form at call time. A field a named scope does not override reports `inherited` when the global user layer carries it; a reset stages the inherited value. The tab's switch lists the roster's presets plus any scope the document holds a section for. The filesystem skill provider gets its first card, a line-list of extra roots. + +**A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab between configuration and the read-only list. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for an acknowledged confirmation listing the dependents the Host reports. The read-only list gains the provenance facts the Host already carried: which layer inserted a preset row and who switched a row off. + +**The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('')` in the workspace against the names the gateway's own source reserves. + +**The web e2e scaffold can mount a profile runtime.** `launchWebScaffold({ profileRuntime })` links fixture packages into the scaffold profile and mounts `ProfileRuntime` over it with `patchReload: 'startup'`, so the manager has a profile to manage while the booted tree never recomposes under a scenario. + +## Alternatives considered + +**An ambient scope selection inside `ui-settings`.** Rejected: surfaces outside the plugins tab bind global-only namespaces, and a page-wide selection would retarget them. + +**Management controls inside the read-only list.** Rejected: the list projects rows of the live tree; management is per package, over the profile manifest, with an install run and confirmations that need their own state. + +**Keeping `install` and special-casing the client mount.** Rejected: the reserved names are the service's own members; renaming one method is cheaper than a second lookup path in the gateway. + +## Consequences + +A preset's settings are editable beside the shared ones, and the document's `scopes.` tree is what the scoped form writes. The plugin manager is usable from the browser end to end: install, switch, retry, uninstall, and compose rows into a preset. Existing plugin-configuration goldens move: the section grows a tab and a scope row. + +## Testing + +`packages/client/ui-settings/tests` pin the registry's routing and the scoped bind; `packages/client/ui-settings-plugins/tests` the scoped forms, the switch, the skills card, and the inherited badge; `packages/client/ui-settings-plugin-manager/tests` the store's reads, actions, install run, confirmations, and the tab's rendering; `packages/client/ui-settings-plugin-inventory/tests` the provenance facts. `apps/web/tests/plugin-manager.e2e.ts` drives the manager over a scaffold profile runtime and writes one field under a preset scope; `plugin-config` and `settings-chrome` goldens are re-recorded for the new tab and scope row. diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md new file mode 100644 index 0000000000..40fc1933de --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md @@ -0,0 +1,37 @@ +# Agent Note:插件管理进入 Web 设置 + +Status: implemented + +[English](2026-09-04-plugin-management-in-web-settings.md) | 中文 + +## 问题 + +宿主已经能管理 profile 的插件——安装、启用、停用、重试、把行组合进预设——也能按具名 scope 解析 settings 命名空间,但浏览器一样都没暴露。「插件」分区只有一个覆盖每个命名空间全局实例的配置标签页与一个只读列表;想要某个预设的值、或不经 CLI 打开一个 bundle 的人没有任何入口。settings 客户端只读一份 `settings.describe` 应答并把每个命名空间绑定在全局,因此按预设的值没有从文档到卡片的路径。 + +## 决定 + +**每个 settings scope 一面镜像。** `ui-settings` 持有 `SettingsMirrorRegistry`:全局镜像如前,每个具名 scope 的镜像在消费方首次绑定或描述它时创建。`settings/document-updated` 事件点名它落在哪个 scope:全局提交重载每一面镜像,因为具名 scope 是在全局分区之上解析的;scoped 提交只重载该 scope。`bind({ namespace, scope })` 从该 scope 的镜像派生并在每次写入时传入 scope;快照携带 `scope`、`registered` 与 `inherited`。 + +**卡片按 scope 暂存,组件不知道 scope。** `ui-settings-plugins` 为配置标签页持有一个 `ScopeSelection`,每张卡片一个 `ScopedCardForms`:每个 scope 一个 `CardForm`,惰性绑定,草稿属于输入它时所在的 scope。卡片组件读同样的 hooks、调同样的 actions,后者在调用时路由到选中的表单。具名 scope 未覆盖而全局用户层携带的字段报告 `inherited`;重置暂存的是继承值。标签页的开关列出 roster 的预设,外加文档已有分区的任何 scope。文件系统技能提供方得到它的第一张卡片:额外目录的逐行列表。 + +**管理另开一个标签页。** `ui-settings-plugin-manager` 在配置与只读列表之间注册**插件管理**标签页。它从 `plugins` Remote 读包、从 `pluginInventory` 读预设组合,在每次操作与每个 `plugins/changed` 之后重新读取,并把 `plugins/install-log` 流进安装对话框。破坏性操作等待一次已勾选确认,确认框列出宿主报告的依赖方。只读列表补上宿主早已携带的出处事实:哪一层插入了预设行、谁停用了某一行。 + +**安装动词是 `add`。** 客户端的命名空间服务把 `install` 与 `remove` 留给自己的成员,并在页面加载时——所有单测都通过之后——拒绝同名的挂载方法。宿主的方法与 CLI 一样叫 `plugins/add`,`packages/api/remotes/tests/remote-method-names.host.spec.ts` 用网关源码自己保留的名字检查工作区里每一个 `@Remote('')`。 + +**web e2e 脚手架可以挂 profile runtime。** `launchWebScaffold({ profileRuntime })` 把 fixture 包链接进脚手架 profile,并以 `patchReload: 'startup'` 在其上挂载 `ProfileRuntime`,于是管理器有 profile 可管,而启动好的树在场景之下绝不重新组合。 + +## 考虑过的替代方案 + +**在 `ui-settings` 内部放一个全局的 scope 选择。** 否决:插件标签页之外的表面绑定的是仅全局的命名空间,页面级选择会把它们改指向别处。 + +**把管理控件放进只读列表。** 否决:列表投影的是在线树的行;管理是按包、基于 profile manifest 的,还有安装运行与确认这些需要自己状态的东西。 + +**保留 `install` 并在客户端挂载里特判。** 否决:保留名是服务自己的成员;改一个方法名比在网关里多一条查找路径便宜。 + +## 后果 + +预设的设置可以在共用设置旁边编辑,scoped 表单写的就是文档的 `scopes.` 树。插件管理器在浏览器里端到端可用:安装、切换、重试、卸载、把行组合进预设。既有的插件配置 golden 会移动:分区多了一个标签页和一行 scope。 + +## 测试 + +`packages/client/ui-settings/tests` 钉住 registry 的路由与 scoped 绑定;`packages/client/ui-settings-plugins/tests` 钉住 scoped 表单、开关、技能卡片与继承标记;`packages/client/ui-settings-plugin-manager/tests` 钉住 store 的读取、操作、安装运行、确认与标签页渲染;`packages/client/ui-settings-plugin-inventory/tests` 钉住出处事实。`apps/web/tests/plugin-manager.e2e.ts` 在脚手架 profile runtime 上驱动管理器并在某个预设 scope 下写一个字段;`plugin-config` 与 `settings-chrome` 的 golden 为新标签页与 scope 行重录。 diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml index 5014e8a675..ac9f101dc2 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml @@ -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-04-plugin-manager-over-the-profile-runtime.md -2026-09-04-plugin-manager-over-the-profile-runtime.md: 8b12c1dcbee5e5f463fcba181441717de5b20c39 -2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 2f1fa6682124677c0614f24ef005f3efc212bd57 +2026-09-04-plugin-manager-over-the-profile-runtime.md: 1622076cde78dc98becf27ec03de3141a8217fbe +2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 3bafbf7902031b78933c6f2ee83da14bf407530b diff --git a/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md index 8b12c1dcbe..1622076cde 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md @@ -10,7 +10,7 @@ Installing a plugin was a terminal-only act: `dsh plugin --profile web add .`) in that order, with the existing field-level merge. A global write re-resolves every instance of the kind, each gated on its own resolved value, so a scope that overrides the changed field is not disturbed; a scoped write commits that instance alone. Revisions are per section, kept off the registrations so a section written for a scope nothing has registered yet — a preset no session composed — is versioned like any other; such a write is accepted when the kind exists and is judged by the shared schema. `scopes` is a reserved namespace. -**Describe per scope.** `describe()` answers one descriptor per kind under the global scope; `describe({ scope })` one per kind under a named scope, with `registered`, the scope's own `user` section, and `inherited` — the value without that section — so a surface can mark a field as overridden, inherited, or default. The controller's `describe(scope?)` and its three write verbs take the same optional trailing `scope`, and both events carry the scope as a trailing argument that is absent for the global instance, which keeps every existing listener and the forwarded-event carrier unchanged. +**Describe per scope.** `describe()` answers one descriptor per kind under the global scope; `describe({ scope })` one per kind under a named scope, with `registered`, the scope's own `user` section, and `inherited` — the value without that section — so a surface can mark a field as overridden, inherited, or default. A scope no owner registered resolves over the global instance's composition base, because a schema may require what only a composition supplies, and a description never throws: layers the schema refuses describe as the layers beneath them, down to an empty section. The controller's `describe(scope?)` and its three write verbs take the same optional trailing `scope`, and both events carry the scope as a trailing argument that is absent for the global instance, which keeps every existing listener and the forwarded-event carrier unchanged. **`skill-filesystem` is the first consumer.** Its `customSkillDirs` resolves through `installSection` with the composition value as base; a change replaces the provider's roots and invalidates the catalog, so a person adds a root for one preset from the settings document while the process runs. diff --git a/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md index 04d5b2f179..6cf041ae3e 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md @@ -16,7 +16,7 @@ settings seam 每个进程只允许注册一次命名空间。这对只存在一 **四层。** 一个 instance 按顺序解析 schema 默认值、自己的组合 `base`、文档的全局段、以及其 scope 的段(`scopes..`),沿用既有的字段级合并。全局写入重新解析该 kind 的每个 instance,各自按自身解析值门控,因此覆盖了被改字段的 scope 不会被打扰;scoped 写入只提交那个 instance。revision 按段记录,与注册分离,于是为尚无注册的 scope——还没有会话组合过的 preset——写入的段与其它段同样被版本化;只要 kind 存在,这样的写入就被接受并由共享 schema 判定。`scopes` 是保留的命名空间。 -**按 scope 描述。** `describe()` 在全局 scope 下每个 kind 回答一条描述符;`describe({ scope })` 在某个具名 scope 下每个 kind 回答一条,附带 `registered`、该 scope 自己的 `user` 段,以及 `inherited`——没有该段时的值——让界面能把字段标为已覆盖、继承或默认。controller 的 `describe(scope?)` 与三个写入动词接受同样的可选尾随 `scope`,两个事件都把 scope 作为尾随参数携带、全局 instance 时缺席,这让每个既有监听器与转发事件载体保持不变。 +**按 scope 描述。** `describe()` 在全局 scope 下每个 kind 回答一条描述符;`describe({ scope })` 在某个具名 scope 下每个 kind 回答一条,附带 `registered`、该 scope 自己的 `user` 段,以及 `inherited`——没有该段时的值——让界面能把字段标为已覆盖、继承或默认。没有注册者的 scope 在全局实例的组合 base 之上解析——schema 可能要求只有组合才提供的字段——而描述从不抛错:schema 拒绝的层按其下的层描述,直到空分区。controller 的 `describe(scope?)` 与三个写入动词接受同样的可选尾随 `scope`,两个事件都把 scope 作为尾随参数携带、全局 instance 时缺席,这让每个既有监听器与转发事件载体保持不变。 **`skill-filesystem` 是第一个消费方。** 它的 `customSkillDirs` 以组合值为 base 经 `installSection` 解析;变化会替换 provider 的根目录并让目录失效,于是一个人可以在进程运行中从 settings 文档为某个 preset 添加一个根目录。 diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 08e89d18f8..059c0d7a2c 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -21,8 +21,14 @@ - paragraph: 配置和查看本部署已安装的插件。 - tablist "插件视图": - tab "插件配置" [selected] + - tab "插件管理" - tab "插件列表" - tabpanel "插件配置": + - text: 生效范围 + - button "选择这些设置生效的 Agent 预设": + - text: 所有预设 + - img + - paragraph: 这里的值对所有 Agent 预设生效,除非某个预设单独覆盖。 - list: - listitem: - 'button "展开设置: 终端"': diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md new file mode 100644 index 0000000000..594550b527 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -0,0 +1,127 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "插件": + - img + - text: 插件 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "插件" [level=2] + - paragraph: 配置和查看本部署已安装的插件。 + - tablist "插件视图": + - tab "插件配置" + - tab "插件管理" [selected] + - tab "插件列表" + - tabpanel "插件管理": + - button "刷新" + - button "添加插件" + - heading "全局插件" [level=3] + - paragraph: 按包安装与启停;启用的 bundle 对系统与所有会话生效 · 2 个 + - list: + - listitem: + - text: 示例组合包 第三方 Bundle 已停用 @fixture/bundle · 0.0.1 + - switch "启用 示例组合包" + - button "展开 示例组合包": + - img + - listitem: + - text: 示例插件 第三方 插件模块 按行添加 @fixture/plain-plugin · 0.0.1 + - button "展开 示例插件": + - img + - heading "会话插件" [level=3] + - button "选择要管理的 Agent 预设": + - text: 标准模式(默认) + - img + - paragraph: 按 Agent 预设组成;这里的改动只写入该预设的用户补丁层 + - list: + - listitem: + - text: persona @deepseek-ai/dsh-persona + - switch "启用行 persona" [checked] + - listitem: + - text: agent-instructions @deepseek-ai/dsh-agent-instructions + - switch "启用行 agent-instructions" [checked] + - listitem: + - text: tool-bash @deepseek-ai/dsh-tool-bash + - switch "启用行 tool-bash" [checked] + - listitem: + - text: tool-pwsh @deepseek-ai/dsh-tool-pwsh 组合停用 + - switch "启用行 tool-pwsh" + - listitem: + - text: tool-fs @deepseek-ai/dsh-tool-fs + - switch "启用行 tool-fs" [checked] + - listitem: + - text: tool-fs-search @deepseek-ai/dsh-tool-fs-search + - switch "启用行 tool-fs-search" [checked] + - listitem: + - text: tool-jobs @deepseek-ai/dsh-tool-jobs + - switch "启用行 tool-jobs" [checked] + - listitem: + - text: skill-filesystem @deepseek-ai/dsh-skill-filesystem + - switch "启用行 skill-filesystem" [checked] + - listitem: + - text: tool-skill @deepseek-ai/dsh-tool-skill + - switch "启用行 tool-skill" [checked] + - listitem: + - text: command-goal @deepseek-ai/dsh-command-goal + - switch "启用行 command-goal" [checked] + - listitem: + - text: tool-goal @deepseek-ai/dsh-tool-goal + - switch "启用行 tool-goal" [checked] + - listitem: + - text: plan-mode @deepseek-ai/dsh-plan-mode + - switch "启用行 plan-mode" [checked] + - listitem: + - text: compaction-basic @deepseek-ai/dsh-compaction-basic + - switch "启用行 compaction-basic" [checked] + - listitem: + - text: command-compact @deepseek-ai/dsh-command-compact + - switch "启用行 command-compact" [checked] + - listitem: + - text: tool-result-pruner @deepseek-ai/dsh-compaction-tool-result-pruner + - switch "启用行 tool-result-pruner" [checked] + - listitem: + - text: tool-subagent-control @deepseek-ai/dsh-tool-subagent-control + - switch "启用行 tool-subagent-control" [checked] + - listitem: + - text: tool-subagent-list-agents @deepseek-ai/dsh-tool-subagent-control/list-agents + - switch "启用行 tool-subagent-list-agents" [checked] + - listitem: + - text: tool-subagent @deepseek-ai/dsh-tool-subagent + - switch "启用行 tool-subagent" [checked] + - listitem: + - text: tool-subagent-fork @deepseek-ai/dsh-tool-subagent + - switch "启用行 tool-subagent-fork" [checked] + - listitem: + - text: tool-subagent-codex @deepseek-ai/dsh-tool-subagent 组合停用 + - switch "启用行 tool-subagent-codex" + - listitem: + - text: tool-subagent-claude-code @deepseek-ai/dsh-tool-subagent 组合停用 + - switch "启用行 tool-subagent-claude-code" + - listitem: + - text: workflow-worker-thread @deepseek-ai/dsh-workflow-worker-thread + - switch "启用行 workflow-worker-thread" [checked] + - listitem: + - text: tool-workflow @deepseek-ai/dsh-tool-workflow + - switch "启用行 tool-workflow" [checked] + - listitem: + - text: tool-ralph @deepseek-ai/dsh-tool-ralph + - switch "启用行 tool-ralph" [checked] + - listitem: + - text: tool-ask-user @deepseek-ai/dsh-tool-ask-user + - switch "启用行 tool-ask-user" [checked] + - listitem: + - text: tool-todo @deepseek-ai/dsh-tool-todo + - switch "启用行 tool-todo" [checked] + - listitem: + - text: tool-web @deepseek-ai/dsh-tool-web + - switch "启用行 tool-web" [checked] diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml new file mode 100644 index 0000000000..99c5aa4a67 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml @@ -0,0 +1,5 @@ +# Web e2e fixture bundle: one inert row, so enabling it changes the profile +# manifest without composing anything the scaffold tree would notice. +- insert: + - id: fixture-row + name: '@fixture/bundle' diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/index.js b/apps/web/tests/fixtures/plugins/fixture-bundle/index.js new file mode 100644 index 0000000000..73273c522a --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/index.js @@ -0,0 +1,5 @@ +/** Web e2e fixture: an inert cordis plugin. */ +export const name = 'fixture-bundle' + +/** Nothing to mount. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json new file mode 100644 index 0000000000..f8fd51e704 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -0,0 +1,14 @@ +{ + "name": "@fixture/bundle", + "version": "0.0.1", + "private": true, + "description": "Web e2e fixture: a bundle whose one row is an inert plugin.", + "type": "module", + "main": "index.js", + "dsh": { + "title": "示例组合包", + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js b/apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js new file mode 100644 index 0000000000..fbc0701260 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js @@ -0,0 +1,5 @@ +/** Web e2e fixture: an inert cordis plugin that a composition adds as one row. */ +export const name = 'fixture-plain-plugin' + +/** Nothing to mount. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json b/apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json new file mode 100644 index 0000000000..adecdfcf02 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json @@ -0,0 +1,11 @@ +{ + "name": "@fixture/plain-plugin", + "version": "0.0.1", + "private": true, + "description": "Web e2e fixture: a plain plugin module with no bundle, added to a composition per row.", + "type": "module", + "main": "index.js", + "dsh": { + "title": "示例插件" + } +} diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts new file mode 100644 index 0000000000..c0aaeff121 --- /dev/null +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -0,0 +1,189 @@ +// Web e2e scenario: the plugin manager tab in Plugins settings over a scaffold +// profile runtime — one installed bundle switched on, one plain plugin added to +// an agent preset and switched off there, and the configuration tab writing one +// field under that preset's settings scope. Zero model calls: everything is +// client state plus the profile files and the settings document, so there is +// no fixture and a stray stream would fail loud on the open llm seam. +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { join } from 'node:path' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url)) +const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md') +const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: plugin manager', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + /** The writable preset root the scenario's preset overlays land in. */ + let presetRoot: string + + beforeAll(async () => { + presetRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')) + scaffold = await launchWebScaffold({ + // The scaffold pins a roster without a user root, so a preset's user + // patch layer needs one: an empty root beside the shipped presets. + agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: 'standard' }, + profileRuntime: { + packages: [ + { dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }, + { dir: join(FIXTURE_PLUGINS, 'fixture-plain-plugin') }, + ], + }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await rm(presetRoot, { recursive: true, force: true }) + }) + + /** Open the settings dialog on the Plugins section and select one of its tabs. */ + async function openPluginsTab(tab: string) { + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) { + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) + } + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '插件', exact: true }).click() + await dialog.getByRole('tab', { name: tab, exact: true }).click() + await expect + .poll(() => dialog.getByRole('tab', { name: tab, exact: true }).getAttribute('aria-selected'), { timeout: 5_000 }) + .toBe('true') + return dialog + } + + /** One file under the harness home, or the empty string while it does not exist. */ + async function homeFile(...segments: string[]): Promise { + return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '') + } + + it('lists the profile packages with their switches and the preset composition', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list')) + const dialog = await openPluginsTab('插件管理') + + await dialog.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 }) + expect(await dialog.getByText('示例插件', { exact: true }).count()).toBe(1) + // The bundle is installed but not enabled; the plain plugin carries no switch. + const toggle = dialog.getByRole('switch', { name: '启用 示例组合包' }) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + expect(await dialog.getByRole('switch', { name: '启用 示例插件' }).count()).toBe(0) + // The default preset's composition renders beside the packages. + expect(await dialog.getByRole('button', { name: '选择要管理的 Agent 预设' }).textContent()).toContain('默认') + expect(await dialog.getByRole('switch', { name: 'Enable row bash' }).count()).toBe(0) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('enables a bundle into the profile manifest and reports the restart it waits for', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable')) + const dialog = await openPluginsTab('插件管理') + const toggle = dialog.getByRole('switch', { name: '启用 示例组合包' }) + await toggle.waitFor({ timeout: 20_000 }) + + await toggle.click() + + await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'package.json')).includes('"@fixture/bundle"'), { + timeout: 10_000, + }).toBe(true) + const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { + dsh: { profile: { bundles: string[] } } + } + expect(manifest.dsh.profile.bundles).toEqual(['@fixture/bundle']) + // A startup-applied profile: the switch is on, and the banner names the package. + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true') + await dialog.getByText('以下更改会在下次启动生效:示例组合包').waitFor({ timeout: 10_000 }) + expect(await dialog.getByText('待重启', { exact: true }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('adds a plain plugin to a preset, switches its row off there, and removes it again', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-preset-row')) + const dialog = await openPluginsTab('插件管理') + await dialog.getByRole('button', { name: '展开 示例插件' }).click() + await dialog.getByRole('button', { name: '添加到…' }).click() + await page.getByRole('menuitem', { name: '预设:标准模式' }).click() + + const overlay = (): Promise => readFile(join(presetRoot, 'standard', 'cordis.patch.yml'), 'utf8').catch(() => '') + await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(true) + expect(await overlay()).toContain('id: fixture/plain-plugin') + // The preset composition lists the new row as the user's, switched on. + const rowToggle = dialog.getByRole('switch', { name: '启用行 fixture/plain-plugin' }) + await rowToggle.waitFor({ timeout: 10_000 }) + expect(await rowToggle.getAttribute('aria-checked')).toBe('true') + expect(await dialog.locator('[data-preset-row="fixture/plain-plugin"]').getAttribute('data-plugin-source')).toBe('user') + + await rowToggle.click() + await expect.poll(async () => (await overlay()).includes('disabled: true'), { timeout: 10_000 }).toBe(true) + await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false') + expect(await dialog.getByText('用户停用', { exact: true }).count()).toBe(1) + + await dialog.getByRole('button', { name: '移除行 fixture/plain-plugin' }).click() + await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(false) + await expect.poll(() => rowToggle.count(), { timeout: 10_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('writes a configuration field under one preset scope and leaves the shared value alone', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-scope')) + const dialog = await openPluginsTab('插件配置') + const scope = dialog.getByRole('button', { name: '选择这些设置生效的 Agent 预设' }) + await scope.waitFor({ timeout: 10_000 }) + expect(await scope.textContent()).toBe('所有预设') + + await scope.click() + await page.getByRole('menuitem', { name: '标准模式(默认)' }).click() + await expect.poll(() => scope.getAttribute('data-settings-scope'), { timeout: 5_000 }).toBe('preset/standard') + await dialog.getByText('这里的值只对本预设生效;未覆盖的字段继承"所有预设"的值。').waitFor({ timeout: 5_000 }) + + await dialog.getByText('终端', { exact: true }).click() + const timeout = dialog.getByLabel('命令超时(毫秒)') + await timeout.waitFor({ timeout: 10_000 }) + expect(await timeout.inputValue()).toBe('60000') + await timeout.fill('12000') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + + const settings = (): Promise => homeFile('settings.yaml') + await expect.poll(async () => (await settings()).includes('timeoutMs: 12000'), { timeout: 10_000 }).toBe(true) + const document = await settings() + expect(document).toContain('scopes:') + expect(document).toContain('preset/standard:') + expect(document.indexOf('scopes:')).toBeLessThan(document.indexOf('timeoutMs: 12000')) + // Back under the shared instance the field still shows the composed default. + await scope.click() + await page.getByRole('menuitem', { name: '所有预设' }).click() + await expect.poll(() => scope.getAttribute('data-settings-scope'), { timeout: 5_000 }).toBe('global') + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + if (await expandTerminal.count() > 0) await expandTerminal.click() + await expect.poll(() => dialog.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('60000') + expect(await dialog.getByText('已覆盖').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 024ac5b49e..b7b8a95b24 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -24,7 +24,7 @@ // assertConsumed for the teardown fixture-consumption check). import { existsSync, readFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -59,7 +59,11 @@ import { assertEntriesLoaded, composeEntries, healProfilesModuleFallback, + loadOptionalPatches, loadOverlayPatches, + loadProfile, + ProfileRuntime, + writeProfileManifest, type Profile, } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' @@ -286,6 +290,18 @@ export interface LaunchOptions { * supply private profile layers named by {@link extraOverlayPath}. */ extraInstallAnchors?: string[] + /** + * Mount a `profileRuntime` over the scaffold profile, so the plugin + * manager has a profile to manage. Each package directory is linked into + * the profile as an installed dependency (`file:` in its manifest, a + * symlink under its `node_modules`); `enabled` lists a bundle in + * `dsh.profile.bundles`. The profile applies layer changes at its next + * start, so an enable or disable is reported as pending and the booted tree + * never recomposes under the scenario. + */ + profileRuntime?: { + packages: { dir: string; enabled?: boolean }[] + } /** * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row * in replay/refresh modes; ignored in record mode (the real adapter @@ -659,6 +675,23 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise = {} + const bundles: string[] = [] + for (const entry of options.profileRuntime.packages) { + const manifest = JSON.parse(await readFile(join(entry.dir, 'package.json'), 'utf8')) as { name: string } + dependencies[manifest.name] = `file:${entry.dir}` + if (entry.enabled === true) bundles.push(manifest.name) + const link = join(profileDir, 'node_modules', manifest.name) + await mkdir(dirname(link), { recursive: true }) + await symlink(entry.dir, link, 'dir') + } + writeProfileManifest(profileDir, { + name: 'dsh-profile-scaffold', + dependencies, + dsh: { profile: { bundles, patchReload: 'startup' } }, + }) + } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') ctx.baseUrl = pathToFileURL(profileDir).href + '/' @@ -681,12 +714,28 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise loadProfile('dsh', 'scaffold', INSTALL_ANCHOR, harnessHome) + const profile = readProfile() + await ctx.plugin(ProfileRuntime, { + profile, + installAnchor: INSTALL_ANCHOR, + loadProfile: readProfile, + compose: () => patches, + rootEntry: () => [...ctx.loader.entries()].find(entry => entry.id === rootIncludeId), + readUserPatches: () => loadOptionalPatches('dsh', profile.patchPath) ?? [], + }) + } if (options.welcomeNoticePending !== true) { await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 23fada7121..1f1c89c3a9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -40,6 +40,7 @@ "tests/lifecycle-chrome.e2e.ts", "tests/details-session-lifecycle.e2e.ts", "tests/plugin-config.e2e.ts", + "tests/plugin-manager.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/default-model.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 829adda101..255e05c074 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 1bc6497abbf1a7abf7b9aed26e512c88702ba1c8 -config-catalog.zh.md: ada410954aa2e30cf73dff1bf5dd96b9c7b27072 +config-catalog.md: e5f86328b6f684c04d3b42bb5b27100fb9326291 +config-catalog.zh.md: 9d75834183f719d08dd3c94e90ba9f9b236c1863 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1bc6497abb..e5f86328b6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3394,6 +3394,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-plugin-manager` ([`packages/client/ui-settings-plugin-manager/src/index.ts`](../packages/client/ui-settings-plugin-manager/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ada410954a..9d75834183 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3396,6 +3396,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-plugin-manager`([`packages/client/ui-settings-plugin-manager/src/index.ts`](../packages/client/ui-settings-plugin-manager/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1c92f865b4..d48ece83f2 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: a5106b0afc58a85a322247cfa41f696a561efdb6 -module-graph.zh.md: c740d26586ca371c07d97e88e4a2bb3cd25adaad +module-graph.md: 3f0da75ac277814b41a1a25d4da5a7471090eade +module-graph.zh.md: 9b268765291bdd8754ad2e30d638f83a006a1b4a diff --git a/docs/module-graph.md b/docs/module-graph.md index a5106b0afc..3f0da75ac2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -164,6 +164,7 @@ flowchart TD pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] + pkg_client_ui_settings_plugin_manager["client-ui-settings-plugin-manager"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_skill["client-ui-skill"] @@ -1218,6 +1219,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | +| [`client-ui-settings-plugin-manager`](../packages/client/ui-settings-plugin-manager) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c740d26586..9b26876529 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -166,6 +166,7 @@ flowchart TD pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] + pkg_client_ui_settings_plugin_manager["client-ui-settings-plugin-manager"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_skill["client-ui-skill"] @@ -1220,6 +1221,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | +| [`client-ui-settings-plugin-manager`](../packages/client/ui-settings-plugin-manager) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 370ea852dc..38e1e24434 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -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 docs/subsystems/core.md -core.md: df34d06554cf11fabd107e5b288329298653e710 -core.zh.md: 85234c6fca3a3584055fa36cfa17fe7aa30c680d +core.md: 0f6dfdaa057f583534bad15f62829998fba5b5ce +core.zh.md: d7ebd49b980c54ef4f6848017fea2db5ab076410 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index df34d06554..0f6dfdaa05 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -888,7 +888,7 @@ Every method that changes the profile reads the manifest afresh and writes it th * or the run times out, `plugins/enable-failed` when enabling was asked * for and the tree rejected the bundle. */ -@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise +@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise /** * Remove a package from the profile: disable it when enabled, drop every diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 85234c6fca..d7ebd49b98 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -898,7 +898,7 @@ Every method that changes the profile reads the manifest afresh and writes it th * or the run times out, `plugins/enable-failed` when enabling was asked * for and the tree rejected the bundle. */ -@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise +@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise /** * Remove a package from the profile: disable it when enabled, drop every diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index f92b29cad5..be3f12898f 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -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 docs/subsystems/settings.md -settings.md: ef06544b500ec21234ce3922d9d7b7a30c3ecc2e -settings.zh.md: b5537332933043df60a62b4ebd8c39af985ebaad +settings.md: 123b099e86e64c31028d7275409ade1b2905fb94 +settings.zh.md: eee382e60787381fd26110829f483465e226899c diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index ef06544b50..123b099e86 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -112,8 +112,8 @@ interface SettingsDescriptor { scope?: SettingsScopeId /** * Whether an owner registered the namespace under this scope. False for a - * scope described from the kind alone — a preset no session composed yet — - * whose value then carries no composition `base`. + * scope no session composed yet, whose value carries no `base` of its own + * and resolves over the global instance's composition when one is registered. */ registered: boolean /** Serialized schemastery schema (`schema.toJSON()`). */ @@ -173,7 +173,8 @@ interface SettingsDescribeOptions { /** * Describe every namespace kind under this named scope instead of the * global scope. A kind with no registration under the scope is described - * from the kind alone, `registered: false`. + * over the global instance's composition, else from the kind alone, + * `registered: false`. */ scope?: string } diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index b553733293..eee382e607 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -112,8 +112,8 @@ interface SettingsDescriptor { scope?: SettingsScopeId /** * Whether an owner registered the namespace under this scope. False for a - * scope described from the kind alone — a preset no session composed yet — - * whose value then carries no composition `base`. + * scope no session composed yet, whose value carries no `base` of its own + * and resolves over the global instance's composition when one is registered. */ registered: boolean /** Serialized schemastery schema (`schema.toJSON()`). */ @@ -173,7 +173,8 @@ interface SettingsDescribeOptions { /** * Describe every namespace kind under this named scope instead of the * global scope. A kind with no registration under the scope is described - * from the kind alone, `registered: false`. + * over the global instance's composition, else from the kind alone, + * `registered: false`. */ scope?: string } diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 91b679bed3..50ac6bd894 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -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/api/gateway/README.md -README.md: 019aedfa1b900fd1e52744e91789b22c59d994e9 -README.zh.md: 5cf91d3f6c4f86333201209aabac9b0f9a58d703 +README.md: c422510c70d7444b0be2c968e55210121f3724d9 +README.zh.md: 77de51c8f979fef9be1302e8f794d545165f76d8 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 019aedfa1b..c422510c70 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -24,7 +24,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) -`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. On the Client, a call names every business argument in order, except that trailing parameters declared `T | undefined` — an optional parameter is one — may be left off, as the generated signature allows; an omitted parameter sends no wire field. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 5cf91d3f6c..77de51c8f9 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -24,7 +24,7 @@ kind: "package-reference" ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。在 Client 侧,调用按顺序给出每个业务参数,只有声明为 `T | undefined` 的尾随参数——可选参数就是这样一种——可以按生成的签名省略;省略的参数不发送 wire 字段。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 853fc4242f..94ab4f1507 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -481,12 +481,20 @@ class ClientRemoteService extends Service implements ClientRemote { boundIdentity?: BoundContextIdentity, ): PreparedClientInvocation { const endpoint = endpointOf(descriptor) - const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) + const business = descriptor.parameters.filter((_parameter, index) => index !== projection?.parameterIndex) + const expected = business.length + // A trailing parameter declared `T | undefined` (an optional parameter is + // one) may be left off the call, as its generated signature allows; its + // wire field is then absent. A caller supplying the AbortSignal names + // every business argument first. + let required = expected + while (required > 0 && business[required - 1]?.acceptsUndefined === true) required -= 1 const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 - if (values.length !== expected && !hasCallerSignal) { + if ((values.length < required || values.length > expected) && !hasCallerSignal) { + const count = required === expected ? String(expected) : `${String(required)} to ${String(expected)}` const contract = descriptor.cancellation === undefined - ? `${String(expected)} argument(s)` - : `${String(expected)} business argument(s) plus an optional AbortSignal` + ? `${count} argument(s)` + : `${count} business argument(s) plus an optional AbortSignal` throw new Error( `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 5b4cbdf3b0..f785f52a5c 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -10,6 +10,7 @@ import { type ConnectionHandle, } from '@deepseek-ai/dsh-client-connection/client' import type { + InvocationParameterDescriptor, InvocationDescriptor, RemoteResult, TypertContextMap, @@ -1111,6 +1112,59 @@ describe('Client Typert API', () => { })).rejects.toThrow('scope must select its only lookup parameter') }) + it('lets a caller omit trailing parameters that accept undefined, as their generated signature allows', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const optionalTail: InvocationDescriptor = { + id: '@fixture/probe#probe/describe', + service: 'probe', + namespace: 'probe', + method: 'describe', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, + }, { + name: 'scope', + wire: 'scope', + source: 'json', + acceptsUndefined: true, + codec: { mode: 'strict', typeSymbol: '@fixture#Scope', schema: z.union([z.undefined(), z.string()]) }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, + } + // A leading parameter accepting undefined earns no omission: the required one after it still counts. + const optionalHead: InvocationDescriptor = { + ...optionalTail, + id: '@fixture/probe#probe/inspect', + method: 'inspect', + parameters: [ + optionalTail.parameters[1] as InvocationParameterDescriptor, + optionalTail.parameters[0] as InvocationParameterDescriptor, + ], + } + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [optionalTail, optionalHead] }) + const probe = ctx.remote.probe as unknown as Record Promise> + const describeCall = probe.describe as (...args: unknown[]) => Promise + const inspect = probe.inspect as (...args: unknown[]) => Promise + + await describeCall({ objective: 'ship' }) + expect((call.mock.calls[0]?.[2] as { args: Record }).args).toEqual({ request: { objective: 'ship' } }) + await describeCall({ objective: 'ship' }, undefined) + expect((call.mock.calls[1]?.[2] as { args: Record }).args).toEqual({ request: { objective: 'ship' } }) + await describeCall({ objective: 'ship' }, 'preset/standard') + expect((call.mock.calls[2]?.[2] as { args: Record }).args) + .toEqual({ request: { objective: 'ship' }, scope: 'preset/standard' }) + await expect(describeCall()).rejects.toThrow('expected 1 to 2 argument(s), got 0') + await expect(describeCall({ objective: 'ship' }, 'preset/standard', 'extra')).rejects.toThrow('got 3') + await expect(inspect(undefined)).rejects.toThrow('expected 2 argument(s), got 1') + await inspect(undefined, { objective: 'ship' }) + expect((call.mock.calls[3]?.[2] as { args: Record }).args).toEqual({ request: { objective: 'ship' } }) + }) + it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) diff --git a/packages/api/remotes/tests/remote-method-names.host.spec.ts b/packages/api/remotes/tests/remote-method-names.host.spec.ts new file mode 100644 index 0000000000..e7df5181f6 --- /dev/null +++ b/packages/api/remotes/tests/remote-method-names.host.spec.ts @@ -0,0 +1,49 @@ +/** + * A Remote method is reached on the browser as `ctx.remote..`, + * a member of the namespace service the client gateway mounts. The mount + * refuses a method named after one of that service's own members — its + * fields and its private `install`/`remove` helpers — and it refuses at page + * load, after every unit suite passed. This spec reads the reserved names + * off the gateway's own source and checks every `@Remote('')` in the + * workspace against them, so the collision fails here instead. + */ + +import { readFileSync } from 'node:fs' +import { globSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) + +/** The names the client namespace service keeps for itself. */ +function reservedMethodNames(): Set { + const source = readFileSync(join(ROOT, 'packages/api/gateway/src/client/index.ts'), 'utf8') + const fields = /const REMOTE_NAMESPACE_FIELDS = new Set\(\[([^\]]*)\]\)/.exec(source)?.[1] + const classBody = /class RemoteNamespaceService extends Service \{([\s\S]*?)\n\}/.exec(source)?.[1] + if (fields === undefined || classBody === undefined) { + throw new Error('the client gateway no longer spells its namespace service the way this spec reads it') + } + const reserved = new Set([...fields.matchAll(/'([^']+)'/g)].map(match => match[1] as string)) + for (const match of classBody.matchAll(/^ {2}(?:private |static |readonly |get |async )*([A-Za-z_$][\w$]*)\s*[(:=]/gm)) { + reserved.add(match[1] as string) + } + return reserved +} + +describe('Remote method names', () => { + it('never name a member of the client namespace service', () => { + const reserved = reservedMethodNames() + expect(reserved.has('install')).toBe(true) + expect(reserved.has('remove')).toBe(true) + const offenders: string[] = [] + for (const file of globSync('packages/*/*/src/**/*.ts', { cwd: ROOT })) { + const source = readFileSync(join(ROOT, file), 'utf8') + for (const match of source.matchAll(/@Remote\(\s*'([^']+)'/g)) { + const method = match[1] as string + if (reserved.has(method)) offenders.push(`${file}: @Remote('${method}')`) + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 0a687198ee..ccd40001ca 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -211,6 +211,11 @@ - id: ui-settings-plugin-inventory name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory' + # Plugin management: install, enable, disable, retry, and compose the + # profile's packages. Without a profile runtime it reports itself unavailable. + - id: ui-settings-plugin-manager + name: '@deepseek-ai/dsh-client-ui-settings-plugin-manager' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index b37676b159..7e0f2ffec8 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-schedule": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 3f826371d5..3b52fac242 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/client/README.md -README.md: 2358d94f6db0455aaf8fce094a616a273d4b3bab -README.zh.md: 4fbeacf33dc609102a78418c3679a43f06914bdf +README.md: 2f15a9b1502b3a85db330b41539c1fd5fd8fa416 +README.zh.md: 3df90cb489cea4af440fbec5a9e129ccff7a09f7 diff --git a/packages/client/README.md b/packages/client/README.md index 2358d94f6d..2f15a9b150 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -67,6 +67,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — | | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | +| [`ui-settings-plugin-manager/`](ui-settings-plugin-manager/README.md) | Contributes the plugin management tab to Plugins settings: install, enable, disable, retry, and compose packages | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | | [`ui-message-feedback/`](ui-message-feedback/README.md) | Contributes per-message feedback controls to the assistant-message action strip | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 4fbeacf33d..3df90cb489 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -67,6 +67,7 @@ kind: "package-group" | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — | | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页 | — | +| [`ui-settings-plugin-manager/`](ui-settings-plugin-manager/README.zh.md) | 向“插件”设置贡献插件管理标签页:安装、启用、停用、重试与组合包 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 向助手消息操作条贡献逐消息反馈控件 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index c86e9c7cdd..04d26a0759 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -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/client/ui-settings-plugin-inventory/README.md -README.md: a9e4108848794bf9168c513a4223dd6769601972 -README.zh.md: ccca8826387f7a97978190eec3175df1b3122a55 +README.md: 5735a243eb2e94439c4ae2ef72c887f69af57cde +README.zh.md: 07b11784489cde73c479c07d7d3b4cd743985232 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index a9e4108848..5735a243eb 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -29,7 +29,7 @@ Open the Plugins section in Settings and select the **Plugin list** tab to inspe ### Reading a card -Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id. +Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, whether the preset's composition or the user's patch layer inserted it, who switched it off when it is disabled, its runtime status when the composition is live, and its disable condition when it carries one; a disabled global row names who switched it off; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id. ### The preset switcher diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index ccca882638..07b1178448 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。 +每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、是预设组合还是用户补丁层插入了它、被停用时是谁停用的、组合存活时的运行状态,以及它携带的禁用条件;被停用的全局行说明是谁停用的;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。 ### 预设切换器 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 058406baaf..6dec691187 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -51,6 +51,12 @@ function phaseLabel(phase: PluginFiberPhase, t: Translate): string { return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) } +/** The fact naming who switched a row off, when something did. */ +function disabledByFact(disabledBy: 'user' | 'composition' | undefined, t: Translate): (readonly [string, ReactNode])[] { + if (disabledBy === undefined) return [] + return [[t('disabledByLabel'), t(disabledBy === 'user' ? 'disabledByUser' : 'disabledByComposition')] as const] +} + /** Compact a module specifier without guessing whether its Loader id was generated. */ function moduleShortName(moduleName: string): string { const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName @@ -81,10 +87,12 @@ function presetLabel(preset: AgentPresetGroup, t: Translate, presetName: (preset } /** One expandable plugin card; the caller owns the trailing status content. */ -function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: { +function PluginCard({ rowKey, moduleName, entryId, source, trailing, ariaLabel, failed, expanded, onToggle, children }: { readonly rowKey: string readonly moduleName: string readonly entryId: string | null + /** Which layer inserted a preset row: its composition, or the user's patch file. */ + readonly source?: 'preset' | 'user' readonly trailing: ReactNode readonly ariaLabel: string readonly failed: boolean @@ -99,6 +107,7 @@ function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, className={css.card} data-plugin-entry={entryId ?? undefined} data-plugin-module={moduleName} + data-plugin-source={source} data-failed={failed ? 'true' : undefined} data-open={open ? 'true' : undefined} > @@ -255,6 +264,7 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent rowKey={key} moduleName={row.moduleName} entryId={row.entryId} + source={row.source} failed={failed} expanded={expanded} onToggle={toggleRow} @@ -274,7 +284,9 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent entryId={row.entryId} facts={[ [t('fromPreset'), presetName(preset)], + [t('sourceLabel'), t(row.source === 'user' ? 'sourceUser' : 'sourcePreset')], [t('configuration'), stateText], + ...disabledByFact(row.disabledBy, t), ...row.fiberPhase === null ? [] : [[t('runtime'), phaseLabel(row.fiberPhase, t)] as const], ...row.condition === undefined ? [] : [[t('condition'), {row.condition}] as const], ]} @@ -336,6 +348,7 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent ] : [ [t('configuration'), t(entry.enabled ? 'enabledTag' : 'disabledTag')], + ...disabledByFact(entry.disabledBy, t), ...entry.enabled ? [[t('runtime'), phaseLabel(entry.fiberPhase, t)] as const] : [], ]} /> diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 07d0cb2556..5c35075183 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -29,6 +29,12 @@ export const zh = { failedTag: '启动失败', moduleLabel: '完整名称', fromPreset: '来自', + sourceLabel: '出处', + sourcePreset: '预设自带', + sourceUser: '用户添加', + disabledByLabel: '停用方', + disabledByUser: '用户在设置中停用', + disabledByComposition: '组合文件停用', condition: '禁用条件', configuration: '配置状态', runtime: '运行状态', @@ -72,6 +78,12 @@ export const en = { failedTag: 'Failed', moduleLabel: 'Module', fromPreset: 'From', + sourceLabel: 'Source', + sourcePreset: 'Preset composition', + sourceUser: 'Added by the user', + disabledByLabel: 'Disabled by', + disabledByUser: 'The user, in settings', + disabledByComposition: 'The composition file', condition: 'Disabled when', configuration: 'Configuration', runtime: 'Status', diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 1a6c4079ef..cf78fc4f55 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -278,6 +278,38 @@ describe('PluginInventorySettingsTab', () => { expect(screen.queryAllByRole('listitem')).toHaveLength(0) }) + it('names where a preset row came from and who switched a row off', async () => { + render( ({ + entries: [ + { entryId: 'dormant', moduleName: '@fixture/dormant', enabled: false, fiberPhase: null, disabledBy: 'composition' }, + ], + agentPresets: [{ + id: 'standard', + trust: 'system', + isDefault: true, + rows: [ + { entryId: 'extra', moduleName: '@fixture/extra', enabled: false, fiberPhase: null, source: 'user', disabledBy: 'user' }, + { entryId: 'own', moduleName: '@fixture/own', enabled: true, fiberPhase: null, source: 'preset' }, + ], + }], + } as unknown as Snapshot))} />) + await screen.findByText(en.presetSubtitle) + + const extra = document.querySelector('[data-plugin-entry="extra"]') + expect(extra?.getAttribute('data-plugin-source')).toBe('user') + fireEvent.click(screen.getByRole('button', { name: 'extra, Disabled' })) + expect(screen.getByText(en.sourceLabel).nextElementSibling?.textContent).toBe(en.sourceUser) + expect(screen.getByText(en.disabledByLabel).nextElementSibling?.textContent).toBe(en.disabledByUser) + + fireEvent.click(screen.getByRole('button', { name: 'own, Enabled' })) + expect(screen.getByText(en.sourceLabel).nextElementSibling?.textContent).toBe(en.sourcePreset) + expect(screen.queryByText(en.disabledByLabel)).toBeNull() + + fireEvent.click(globalToggle()) + fireEvent.click(screen.getByRole('button', { name: 'dormant, Disabled' })) + expect(screen.getByText(en.disabledByLabel).nextElementSibling?.textContent).toBe(en.disabledByComposition) + }) + it('renders a rosterless deployment as one expanded global list', async () => { const view = await renderReady({ entries: [ diff --git a/packages/client/ui-settings-plugin-manager/README.i18n.yaml b/packages/client/ui-settings-plugin-manager/README.i18n.yaml new file mode 100644 index 0000000000..b7b49097f5 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/client/ui-settings-plugin-manager/README.md +README.md: 07e7378a6b34538bd24db3e52ee67cd383c2bb29 +README.zh.md: 05c790300315d7c0bc3dd769324a9a82f49999a8 diff --git a/packages/client/ui-settings-plugin-manager/README.md b/packages/client/ui-settings-plugin-manager/README.md new file mode 100644 index 0000000000..07e7378a6b --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/README.md @@ -0,0 +1,112 @@ +--- +description: "Plugin management tab in Web Plugins settings for the dsh web client: install packages through pnpm, enable and disable bundles, retry failed ones, and compose rows into the global user layer or one agent preset's." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-plugin-manager + +English | [中文](README.zh.md) + +## Summary + +`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The global group lists one card per package with its trust, kind, stage, and status tags; a bundle carries a switch that puts it into or takes it out of the profile's layer list, and an expanded card shows the rows it contributes with their phase and failure, the built-in rows it overrides, the modules it declares addable — each with an **Add to…** menu naming the global layer and every preset — and the retry and uninstall actions the package offers. The session group shows the selected preset's composition with a switch per row and a remove action for rows the user added. The install dialog takes an npm spec, a local path, or a git URL, streams pnpm's output as `plugins/install-log` chunks arrive, and reports what the run added. A disable with dependents and every uninstall wait for an acknowledged confirmation that lists the services other rows inject and the user-layer rows naming the package. Without a profile runtime the tab says it is unavailable and offers nothing. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Open the Plugins section in Settings and select the **Manage plugins** tab. The tab reads no Remote during plugin activation — selecting it mounts the component, which then reads the packages and the preset compositions through `api-remotes`. + +### Installing a package + +**Add plugin** opens the install dialog. Enter what pnpm accepts — `dsh-better-sidebar@latest`, `/path/to/plugin`, a git URL — and choose whether a newly installed bundle is enabled right away. The dialog streams the run's output and, once pnpm exits, names the dependencies the run added; a non-zero exit keeps the output for reading. The dialog cannot be closed while the run is in flight. + +### Switching a bundle + +A bundle's switch calls `plugins.enable` or `plugins.disable`. On a live-reload profile the tree recomposes before the switch settles; a profile that applies layer changes at its next start reports the change as pending, and the tab names every such package in a banner until the restart. A built-in bundle's switch is locked; a bundle the probe refused cannot be switched on and shows the probe's reason. A disable first asks the Host what it would strand and skips the confirmation when nothing does. + +### Composing rows + +An expanded card lists the modules the package declares addable; **Add to…** writes a row naming the module into the global user patch file or one preset's, through `plugins.addRow`. The session group's preset switcher shows one preset's composition: a row's switch writes `disabled: true` into that preset's user patch layer or removes the key again, and a row the user added can be removed. Rows that declare no id cannot be switched here. + +### Reading a failure + +The last action's outcome sits above the groups: a restart notice, a done notice, or the failure with the Host's own reason — a probe refusal, a rejected recomposition, a row id already taken. Dismiss it or let the next action replace it. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +### Registration + +The browser plugin registers one localized `settings.plugins.tab` contribution with id `manage` and order 5, between the configuration tab and the read-only list. Registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner. Preset names resolve through the shared `presetDisplayText` fold over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries, as the inventory tab does. + +### The store + +`PluginManagerController` holds one snapshot: the read status, the packages, the preset groups, the busy keys, the notice, the install dialog, and the pending confirmation. `load` folds concurrent reads into one in-flight read plus one rerun, so an invalidation landing mid-read is never lost. Every action runs under a busy key — the package name, or `:` for a row — turns a refused answer into the notice with the Host's code and reason, and re-reads afterwards whatever happened. The plugin's `apply` subscribes `plugins/changed` and `connection/reset` to reload a tab that has rendered once, and `plugins/install-log` to fold chunks whose spec matches the open run. + +### Confirmation + +`disable` and `uninstall` open the confirmation and ask `plugins.dependents`; the answer for a confirmation that has since changed is dropped. A disable with an empty answer confirms itself. The action the confirmation guards is captured when it opens and runs only through **Continue**. + +
+ +----- + + +## Further Exploration + +These pages cover the settings section, the Remote calls, and the Host-side manager. + +- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Plugins section this tab registers into. +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only list beside this tab. +- [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `plugins.*` and `pluginInventory.list()`. +- [plugin-manager](../../host/plugin-manager/README.md) — the Host-side manager this tab drives. + +----- + + +## Model Experience + +None, as the package is a browser-side management surface that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + + +These limits define the reach of the management view; they are current package constraints. + +- **Global user-layer rows are not listed per package** — a row added to the global layer shows in the read-only list and in the package's dependents, not on the package card; removing it goes through the patch file or a future row list. +- **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish. +- **No version picker** — the spec is typed as pnpm accepts it; the tab neither lists registry versions nor offers upgrades. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. This package owns a Settings contribution over Host-owned facts. diff --git a/packages/client/ui-settings-plugin-manager/README.zh.md b/packages/client/ui-settings-plugin-manager/README.zh.md new file mode 100644 index 0000000000..05c7903003 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/README.zh.md @@ -0,0 +1,112 @@ +--- +description: "dsh Web 客户端设置里的插件管理标签页:经 pnpm 安装包、启停 bundle、重试失败的 bundle,并把行组合进全局用户层或某个 Agent 预设。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-plugin-manager + +[English](README.md) | 中文 + +## 概述 + +`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。全局组为每个包列出一张卡片,带信任、种类、阶段与状态标签;bundle 携带一个开关,把它放入或移出 profile 的层列表;展开的卡片显示它贡献的行及各行的阶段与失败、它覆盖的内置行、它声明可添加的模块——每个模块带一个列出全局层与每个预设的**添加到…**菜单——以及该包提供的重试与卸载操作。会话组显示选中预设的组合,每行一个开关,用户添加的行还有移除操作。安装对话框接受 npm 包名、本地路径或 git 地址,随 `plugins/install-log` 块到达流式显示 pnpm 输出,并报告这次运行新增了什么。有依赖方的停用与每次卸载都要等待一次已勾选确认,确认框列出其他行注入的服务与引用该包的用户层行。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +打开设置中的「插件」分区并选择**插件管理**标签页。插件激活期间不会读取 Remote——选择该标签页时才挂载组件,组件再通过 `api-remotes` 读取包与预设组合。 + +### 安装一个包 + +**添加插件**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、git 地址——并选择新装的 bundle 是否立即启用。对话框流式显示运行输出,pnpm 退出后列出这次运行新增的依赖;非零退出会保留输出供阅读。运行进行中对话框不能关闭。 + +### 切换一个 bundle + +bundle 的开关调用 `plugins.enable` 或 `plugins.disable`。在实时重载的 profile 上,树会在开关落定前重新组合;在下次启动才应用层变更的 profile 上,改动被报告为待生效,标签页会在横幅里点名每个这样的包直到重启。内置 bundle 的开关被锁定;探针拒绝的 bundle 无法打开并显示探针的原因。停用会先询问宿主它会搁置什么,没有依赖方时跳过确认。 + +### 组合行 + +展开的卡片列出该包声明可添加的模块;**添加到…**通过 `plugins.addRow` 把一行写进全局用户补丁文件或某个预设的补丁文件。会话组的预设切换器显示某个预设的组合:行的开关往该预设的用户补丁层写入 `disabled: true` 或再次删掉这个键,用户添加的行可以移除。没有声明 id 的行不能在这里切换。 + +### 阅读失败 + +上一次操作的结果停在两组之上:重启提示、完成提示,或带宿主原因的失败——探针拒绝、被拒的重新组合、已被占用的行 id。可以关掉它,也可以让下一次操作替换它。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +### 注册 + +浏览器插件注册一个 id 为 `manage`、order 为 5 的本地化 `settings.plugins.tab` 贡献,位于配置标签页与只读列表之间。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。预设名与清单标签页一样,经共享的 `presetDisplayText` 纯函数叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析。 + +### store + +`PluginManagerController` 持有一份快照:读取状态、包、预设组、忙碌键、提示、安装对话框与待确认项。`load` 把并发读取折叠成一次在途读取加一次重跑,因此读取中途到达的失效不会丢失。每个操作在一个忙碌键下运行——包名,或行的 `:`——把被拒的应答转成带宿主代码与原因的提示,并且无论结果如何随后重新读取。插件的 `apply` 订阅 `plugins/changed` 与 `connection/reset` 以重载渲染过的标签页,订阅 `plugins/install-log` 以折叠 spec 与打开的运行匹配的块。 + +### 确认 + +`disable` 与 `uninstall` 打开确认框并询问 `plugins.dependents`;确认框此后已变化时丢弃这份应答。空应答的停用自行确认。确认框守护的操作在打开时捕获,只经**继续**运行。 + +
+ +----- + + +## 进一步探索 + +这些页面覆盖设置分区、Remote 调用与宿主侧管理器。 + +- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——本标签页注册进的「插件」分区。 +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——本标签页旁边的只读列表。 +- [api-remotes](../../api/remotes/README.zh.md)——`plugins.*` 与 `pluginInventory.list()` 背后的 Remote BFF 表面。 +- [plugin-manager](../../host/plugin-manager/README.zh.md)——本标签页驱动的宿主侧管理器。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的管理表面,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;本包既不组装也不发送 provider 请求。 + +## 已知限制与延期工作 + + + + +这些限制界定管理视图的范围;它们是本包当前的约束。 + +- **全局用户层的行不按包列出**——添加到全局层的行出现在只读列表与该包的依赖方里,而不在包卡片上;移除它要走补丁文件或将来的行列表。 +- **一次只能安装一个**——对话框运行一条 pnpm 命令;第二个 spec 要等第一个结束。 +- **没有版本选择器**——spec 按 pnpm 接受的写法输入;标签页既不列出仓库版本也不提供升级。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**运行时不变量:** 不发布 companion。本包拥有一个基于宿主事实的设置贡献。 diff --git a/packages/client/ui-settings-plugin-manager/package.json b/packages/client/ui-settings-plugin-manager/package.json new file mode 100644 index 0000000000..e04164101e --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/package.json @@ -0,0 +1,73 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-plugin-manager", + "description": "Plugin management tab in Web Plugins settings: install, enable, disable, retry, and compose installed plugin packages", + "version": "0.1.2-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-plugin-manager" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-plugins", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-agent-preset" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-host-plugin-manager": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "clsx": "^2.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css new file mode 100644 index 0000000000..b2266945f8 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css @@ -0,0 +1,484 @@ +/* Plugin manager tab: toolbar, notices, the package cards, and the preset composition. */ + +.section { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + max-width: 760px; + color: var(--dsw-alias-label-primary); +} + +.toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.status, +.failure p, +.empty { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.failure { + display: flex; + align-items: center; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.banner { + margin: 0; + padding: 8px 12px; + border-radius: 10px; + font-size: 12px; + line-height: 18px; + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent); + color: var(--dsw-alias-label-primary); +} + +.notice { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + padding: 8px 12px; + border-radius: 10px; + font-size: 12px; + line-height: 18px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); +} + +.notice[data-kind='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.notice span { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.groupTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.groupTitle { + margin: 0; + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +.groupSub { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.switcher { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 320px; + height: 30px; + padding: 0 10px 0 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 999px; + corner-shape: round; + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.switcher:focus-visible, +.linkButton:focus-visible, +.switch:focus-visible, +.iconButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + +.switcherLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.cards { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.card { + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 14px; + background: var(--dsw-alias-bg-layer-3); +} + +.card[data-open='true'] { + background: var(--dsw-alias-bg-layer-2); + border-color: var(--dsw-alias-label-dimmed); +} + +.cardHead { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; +} + +.cardMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.cardTitleRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.cardTitle { + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +.cardName { + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardDescription { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.tag { + display: inline-flex; + align-items: center; + min-height: 18px; + border-radius: 5px; + padding: 0 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +.tag[data-kind='running'], +.tag[data-kind='enabled'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.tag[data-kind='failed'], +.tag[data-kind='not-enableable'] { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.tag[data-kind='partial'], +.tag[data-kind='restart-required'] { + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); + color: var(--dsw-alias-state-business-primary); +} + +.cardEnd { + display: inline-flex; + flex: none; + align-items: center; + gap: 8px; +} + +.switch { + box-sizing: border-box; + position: relative; + flex: 0 0 auto; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switchOn { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switchOn .thumb { + transform: translateX(16px); +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.chevronOpen { + transform: rotate(180deg); +} + +.cardBody { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0 14px; + padding: 10px 0 12px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + column-gap: 12px; + row-gap: 4px; + margin: 0; + font-size: 12px; + line-height: 18px; +} + +.facts dt { + color: var(--dsw-alias-label-tertiary); +} + +.facts dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-primary); +} + +.reason { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} + +.rows { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 2px 8px; + border-radius: 8px; + background: var(--dsw-alias-bg-layer-1); + font-size: 12px; + line-height: 18px; +} + +.rowId { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--dsw-font-mono, monospace); +} + +.rowModule { + color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rowFailure { + flex-basis: 100%; + margin: 0; + font-size: 12px; + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} + +.statusDot { + display: inline-block; + width: 7px; + height: 7px; + flex: none; + border-radius: 999px; + corner-shape: round; + background: var(--dsw-alias-label-tertiary); +} + +.statusDot[data-phase='active'] { + background: var(--dsw-alias-state-success-primary); +} + +.statusDot[data-phase='failed'] { + background: var(--dsw-alias-state-error-primary); +} + +.statusDot[data-phase='loading'], +.statusDot[data-phase='pending'] { + background: var(--dsw-alias-state-business-primary); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.linkButton { + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 8px; + padding: 4px 10px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.linkButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.linkButton:disabled { + opacity: 0.5; + cursor: default; +} + +.linkButton[data-danger='true'] { + color: var(--dsw-alias-state-error-primary); +} + +.installBody { + display: flex; + flex-direction: column; + gap: 10px; + min-width: min(520px, 80vw); +} + +.installField { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.installField input[type='text'] { + height: 34px; + padding: 0 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 8px; + background: var(--dsw-alias-bg-layer-3); + font: inherit; + font-size: 13px; + color: var(--dsw-alias-label-primary); +} + +.installOption { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +/* The log scrolls on the dialog's elevated surface: rebind the bar to the elevation pair. */ +.log { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + max-height: 240px; + margin: 0; + padding: 10px 12px; + overflow: auto; + border-radius: 8px; + background: var(--dsw-alias-bg-layer-1); + font-family: var(--dsw-font-mono, monospace); + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.dependents { + margin: 8px 0 0; + padding-left: 18px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx new file mode 100644 index 0000000000..7ac7acbad5 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx @@ -0,0 +1,615 @@ +/** + * The Manage plugins tab: the profile's packages as cards with their switch, + * rows, and actions; the selected agent preset's composition with per-row + * switches; the install dialog streaming pnpm's output; and the confirmation + * a destructive action waits on, listing what it would strand. + */ + +import { useEffect, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import type { PluginPackageView, PluginRowTarget } from '@deepseek-ai/dsh-api-remotes/client' +import { + Button, IconChevronDownOutline14, Menu, Modal, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginManagerLocaleKey } from './locales.ts' +import type { + ConfirmState, InstallState, ManagerNotice, PluginManagerFace, PresetGroup, PresetRow, +} from './manager-store.ts' +import { rowKey } from './manager-store.ts' +import css from './PluginManagerSettingsTab.module.css' + +/** Full component props assembled by the Settings slot renderer. */ +export type PluginManagerSettingsTabProps = + PropsRuntime<'settings.plugins.tab'> + & PropsLocale<'settings.pluginManager'> + & InjectFace + +type Translate = PluginManagerSettingsTabProps['t'] +type RowView = PluginPackageView['rows'][number] +type RowPhase = NonNullable + +const PHASE_KEYS = { + pending: 'rowPhasePending', + loading: 'rowPhaseLoading', + active: 'rowPhaseActive', + failed: 'rowPhaseFailed', + unloading: 'rowPhaseUnloading', +} satisfies Record + +const STATUS_KEYS = { + 'running': 'statusRunning', + 'partial': 'statusPartial', + 'failed': 'statusFailed', + 'disabled': 'statusDisabled', + 'not-enableable': 'statusNotEnableable', + 'restart-required': 'statusRestartRequired', + 'plain': 'statusPlain', +} satisfies Record + +const KIND_KEYS = { + bundle: 'bundleTag', + plugin: 'pluginTag', + library: 'libraryTag', +} satisfies Record + +/** Compact a package name to what a person calls it. */ +function shortName(name: string): string { + const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name + return unscoped.replace(/^dsh-(?:host-|client-)?/, '') +} + +/** The roster row shown when the switcher has no explicit choice. */ +function fallbackPreset(presets: readonly PresetGroup[]): PresetGroup | undefined { + return presets.find(preset => preset.isDefault) ?? presets[0] +} + +function presetLabel(preset: PresetGroup, t: Translate, presetName: (preset: PresetGroup) => string): string { + const name = presetName(preset) + if (preset.broken !== undefined) return t('presetOptionBroken', { name }) + if (preset.isDefault) return t('presetOptionDefault', { name }) + return name +} + +/** Status dot naming a live root-fiber phase. */ +function PhaseDot({ phase, t }: { readonly phase: RowPhase; readonly t: Translate }): ReactNode { + const label = t(PHASE_KEYS[phase]) + return +} + +function Tag({ kind, children }: { readonly kind: string; readonly children: ReactNode }): ReactNode { + return {children} +} + +function Switch({ checked, label, disabled, title, onChange }: { + readonly checked: boolean + readonly label: string + readonly disabled: boolean + readonly title?: string + readonly onChange: (checked: boolean) => void +}): ReactNode { + return ( + + ) +} + +/** One package: its header with the switch, and its details once expanded. */ +function PackageCard({ pkg, t, busy, open, presets, presetName, onToggleOpen, onSetEnabled, onRetry, onUninstall, onAddRow }: { + readonly pkg: PluginPackageView + readonly t: Translate + readonly busy: boolean + readonly open: boolean + readonly presets: readonly PresetGroup[] + readonly presetName: (preset: PresetGroup) => string + readonly onToggleOpen: () => void + readonly onSetEnabled: (enabled: boolean) => void + readonly onRetry: () => void + readonly onUninstall: () => void + readonly onAddRow: (declaredName: string, target: PluginRowTarget) => void +}): ReactNode { + const [addMenu, setAddMenu] = useState(null) + const title = pkg.title ?? shortName(pkg.name) + const bundle = pkg.kind === 'bundle' + const locked = pkg.trust === 'builtin' + const detailId = `plugin-package-${encodeURIComponent(pkg.name)}` + const targets = [ + { id: 'global', label: t('addToGlobal') }, + ...presets.map(preset => ({ id: `preset:${preset.id}`, label: t('addToPreset', { name: presetName(preset) }) })), + ] + const targetOf = (id: string): PluginRowTarget => + id === 'global' ? { kind: 'global' } : { kind: 'preset', preset: id.slice('preset:'.length) } + return ( +
  • +
    +
    +
    + {title} + {t(pkg.trust === 'builtin' ? 'builtinTag' : 'externalTag')} + {t(KIND_KEYS[pkg.kind])} + {pkg.stage === 'boot' ? {t('stageBootTag')} : null} + {t(STATUS_KEYS[pkg.status])} +
    + {pkg.name}{pkg.version === undefined ? '' : ` · ${pkg.version}`} +
    +
    + {bundle + ? ( + + ) + : null} + +
    +
    + {open + ? ( +
    + {pkg.description === undefined ? null :

    {pkg.description}

    } + {pkg.reason === undefined ? null :

    {t('reasonLabel')}: {pkg.reason}

    } +
    +
    {t('packageLabel')}
    +
    {pkg.name}
    + {pkg.enginesDsh === undefined ? null : <>
    {t('enginesLabel')}
    {pkg.enginesDsh}
    } +
    {t('cordisLabel')}
    +
    {t(pkg.cordisSameCopy === null ? 'cordisUnknown' : pkg.cordisSameCopy ? 'cordisSame' : 'cordisForeign')}
    + {pkg.probedAt === undefined ? null : <>
    {t('probedAtLabel')}
    {pkg.probedAt}
    } + {pkg.overrides.length === 0 ? null : <>
    {t('overridesLabel')}
    {pkg.overrides.join(', ')}
    } +
    + {bundle + ? ( + <> +

    {t('rowsLabel')}

    + {pkg.rows.length === 0 + ?

    {t('rowsEmpty')}

    + : ( +
      + {pkg.rows.map(row => ( +
    • + {row.enabled && row.phase !== null ? : null} + {row.originalId ?? row.entryId} + {row.moduleName} + {row.enabled + ? null + : {t(row.disabledBy === 'user' ? 'rowDisabledByUser' : 'rowDisabledByComposition')}} + {row.failure === undefined ? null :

      {row.failure.message}

      } +
    • + ))} +
    + )} + + ) + : null} + {pkg.addable.length === 0 + ? null + : ( + <> +

    {t('addableLabel')}

    +
      + {pkg.addable.map(entry => ( +
    • + {entry.title ?? entry.declaredName} + {entry.moduleName} + {entry.ok + ? ( + { setAddMenu(null) }} + items={targets} + onSelect={(id) => { + setAddMenu(null) + onAddRow(entry.declaredName, targetOf(id)) + }} + align="end" + portal + anchor={( + + )} + /> + ) + : {t('addableNotOk')}} + {entry.error === undefined ? null :

      {entry.error}

      } +
    • + ))} +
    + + )} +
    + {bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial') + ? + : null} + {pkg.installed && !locked + ? ( + + ) + : null} +
    +
    + ) + : null} +
  • + ) +} + +/** One row of the selected preset's composition, with its switch. */ +function PresetRowItem({ preset, row, t, busy, onSetDisabled, onRemove }: { + readonly preset: PresetGroup + readonly row: PresetRow + readonly t: Translate + readonly busy: boolean + readonly onSetDisabled: (rowId: string, disabled: boolean) => void + readonly onRemove: (rowId: string) => void +}): ReactNode { + const id = row.entryId + const failed = row.fiberPhase === 'failed' + const enabled = row.enabled === true + return ( +
  • + {enabled && !failed && row.fiberPhase !== null ? : null} + {id ?? shortName(row.moduleName)} + {row.moduleName} + {row.source === 'user' ? {t('rowSourceUser')} : null} + {failed ? {t('rowPhaseFailed')} : null} + {row.enabled === 'conditional' ? {t('rowConditional')} : null} + {row.enabled === false + ? {t(row.disabledBy === 'user' ? 'rowDisabledByUser' : 'rowDisabledByComposition')} + : null} + {id === null + ? {t('rowNoId')} + : ( + <> + { onSetDisabled(id, !checked) }} + /> + {row.source === 'user' + ? ( + + ) + : null} + + )} +
  • + ) +} + +/** The install dialog: the spec, the enable choice, and pnpm's output. */ +function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun }: { + readonly install: InstallState + readonly t: Translate + readonly onClose: () => void + readonly onEditSpec: (text: string) => void + readonly onToggleEnable: () => void + readonly onRun: () => void +}): ReactNode { + const running = install.phase === 'running' + return ( + + + + + )} + > +
    + + + {install.phase === 'done' + ? ( +

    + {install.installed.length === 0 ? t('installDoneNothing') : t('installDone', { names: install.installed.join(', ') })} +

    + ) + : null} + {install.phase === 'failed' ?

    {t('installFailed')}

    : null} + {install.log === '' && install.phase === 'idle' + ? null + :
    {install.log}
    } +
    +
    + ) +} + +/** The confirmation a destructive action waits on, listing what it would strand. */ +function ConfirmDialog({ confirm, t, presets, presetName, onAcknowledge, onConfirm, onCancel }: { + readonly confirm: ConfirmState + readonly t: Translate + readonly presets: readonly PresetGroup[] + readonly presetName: (preset: PresetGroup) => string + readonly onAcknowledge: (acknowledged: boolean) => void + readonly onConfirm: () => void + readonly onCancel: () => void +}): ReactNode { + const name = shortName(confirm.packageName) + const targetText = (target: PluginRowTarget): string => { + if (target.kind === 'global') return t('targetGlobal') + const preset = presets.find(candidate => candidate.id === target.preset) + return t('targetPreset', { name: preset === undefined ? target.preset : presetName(preset) }) + } + const dependents = confirm.dependents + const lines = dependents === undefined + ? [] + : [ + ...dependents.services.map(service => t('dependentService', { + service: service.service, provider: service.providedBy, rows: service.injectedBy.join(', '), + })), + ...dependents.references.map(reference => t('dependentReference', { + row: reference.rowId, target: targetText(reference.target), module: reference.moduleName, + })), + ] + return ( + + + + + )} + > + {dependents === undefined ?

    {t('loading')}

    : null} + {lines.length > 0 + ? ( + <> +

    {t('confirmDependents')}

    +
      {lines.map(line =>
    • {line}
    • )}
    + + ) + : null} + +
    + ) +} + +function noticeText(notice: ManagerNotice, t: Translate): string { + switch (notice.kind) { + case 'restart': return t('restartNotice') + case 'done': return t('doneNotice') + case 'failed': { + switch (notice.code) { + case 'plugins/not-enableable': return t('notEnableable', { reason: notice.reason }) + case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason }) + case 'plugins/row-conflict': return t('rowConflict', { row: notice.rowId ?? '' }) + case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' }) + default: return t('actionFailed', { reason: notice.reason }) + } + } + } +} + +/** Render the plugin manager: packages first, then the selected preset's composition. */ +export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode { + const { t, presetName, ensure } = props + const state = props.usePluginManager(snapshot => snapshot) + const [expanded, setExpanded] = useState(null) + const [switcherOpen, setSwitcherOpen] = useState(false) + useEffect(() => { ensure() }, [ensure]) + + const selected = state.presets.find(preset => preset.id === state.selectedPreset) ?? fallbackPreset(state.presets) + const restartPending = state.packages.filter(pkg => pkg.status === 'restart-required').map(pkg => pkg.title ?? shortName(pkg.name)) + const loaded = state.status === 'ready' || state.status === 'error' + + return ( +
    +
    + + +
    + {state.status === 'loading' ?

    {t('loading')}

    : null} + {state.status === 'unavailable' ?

    {t('unavailable')}

    : null} + {state.status === 'error' + ? ( +
    +

    {t('error')}

    + +
    + ) + : null} + {restartPending.length > 0 + ?

    {t('restartBanner', { names: restartPending.join(', ') })}

    + : null} + {state.notice === null + ? null + : ( +

    + {noticeText(state.notice, t)} + +

    + )} + {loaded + ? ( + <> +
    +
    +

    {t('globalTitle')}

    +
    +

    + {t('globalSubtitle')} + {` · ${String(state.packages.length)} ${t('countUnit')}`} +

    + {state.packages.length === 0 + ?

    {t('empty')}

    + : ( +
      + {state.packages.map(pkg => ( + { setExpanded(current => current === pkg.name ? null : pkg.name) }} + onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} + onRetry={() => { props.retry(pkg.name) }} + onUninstall={() => { props.uninstall(pkg.name) }} + onAddRow={(declaredName, target) => { props.addRow(pkg.name, declaredName, target) }} + /> + ))} +
    + )} +
    +
    +
    +

    {t('presetTitle')}

    + {selected === undefined + ? null + : ( + { setSwitcherOpen(false) }} + items={state.presets.map(preset => ({ id: preset.id, label: presetLabel(preset, t, presetName) }))} + selectedId={selected.id} + onSelect={(id) => { + setSwitcherOpen(false) + props.selectPreset(id) + }} + align="end" + portal + anchor={( + + )} + /> + )} +
    +

    {t('presetSubtitle')}

    + {selected === undefined + ?

    {t('presetNoRoster')}

    + : selected.broken !== undefined + ?

    {selected.broken}

    + : selected.rows.length === 0 + ?

    {t('presetRowsEmpty')}

    + : ( +
      + {selected.rows.map((row, index) => ( + { props.setRowDisabled({ kind: 'preset', preset: selected.id }, rowId, disabled) }} + onRemove={(rowId) => { props.removeRow({ kind: 'preset', preset: selected.id }, rowId) }} + /> + ))} +
    + )} +
    + + ) + : null} + + {state.confirm === null + ? null + : ( + + )} +
    + ) +} diff --git a/packages/client/ui-settings-plugin-manager/src/client/index.ts b/packages/client/ui-settings-plugin-manager/src/client/index.ts new file mode 100644 index 0000000000..b0f6eb223e --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/src/client/index.ts @@ -0,0 +1,82 @@ +/** + * Plugin manager, browser half: the **Manage plugins** tab of the Plugins + * settings section. It installs, enables, disables, retries, and uninstalls + * the packages of the Host's profile through the `plugins` Remote, and + * composes rows into the global user layer or one agent preset's. + */ + +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the `settings.plugins.tab` slot this tab registers into is +// declared by ui-settings-plugins; registration goes through `slots.inject`. +import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remote Context merge and the forwarded-event key face. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: the forwarded events' own declaration (`$on`'s key face resolves +// through the owning package's client-safe types subpath). +import type {} from '@deepseek-ai/dsh-host-plugin-manager/types' +// Type-only: pulls the 'settings.agentPreset' LocaleNamespaceMap merge, whose +// dictionaries the shipped-preset name resolution below reads. +import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client' +// Inline-safe shared fold: shipped ids map to dictionary keys in one home. +import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display' +import { PluginManagerSettingsTab } from './PluginManagerSettingsTab.tsx' +import { PluginManagerController } from './manager-store.ts' +import { en, zh, type PluginManagerLocaleKey } from './locales.ts' + +export type { PluginManagerSettingsTabProps } from './PluginManagerSettingsTab.tsx' +export type { + ConfirmState, InstallState, ManagerNotice, PluginManagerFace, PluginManagerState, PresetGroup, PresetRow, +} from './manager-store.ts' +export type { PluginManagerLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Plugin manager tab copy. */ + 'settings.pluginManager': PluginManagerLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.pluginManager' + +/** Services required by the Settings registration and the two Remote faces. */ +export const inject = ['slots', 'locale', 'remote', 'remote.plugins', 'remote.pluginInventory'] + +/** + * Contribute the manager tab to the Plugins settings section, and keep it + * current on the Host's change events. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugin-manager: dictionaries') + const t = ctx.locale.bind(NS) + const agentPresetCopy = ctx.locale.bind('settings.agentPreset') + const controller = new PluginManagerController(ctx, preset => presetDisplayText(preset, agentPresetCopy).name) + ctx.effect(() => () => { controller.dispose() }, 'ui-settings-plugin-manager: controller') + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + ctx.effect(() => { + // A tab never rendered holds no snapshot to refresh. + const refresh = (): void => { + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + const disposers = [ + ctx.remote.$on('plugins/changed', refresh), + ctx.remote.$on('plugins/install-log', (chunk) => { controller.appendLog(chunk) }), + ctx.on('connection/reset', refresh), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-plugin-manager: host invalidations') + + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'manage', + order: 5, + label: () => t('tab'), + locale: NS, + inject: () => controller.inject(), + }, PluginManagerSettingsTab)) +} diff --git a/packages/client/ui-settings-plugin-manager/src/client/locales.ts b/packages/client/ui-settings-plugin-manager/src/client/locales.ts new file mode 100644 index 0000000000..0e562142e6 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/src/client/locales.ts @@ -0,0 +1,216 @@ +/** Copy dictionaries for the plugin manager Settings tab. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + tab: '插件管理', + loading: '正在读取插件…', + error: '暂时无法读取插件。', + unavailable: '本部署没有可管理的 profile,无法安装或启停插件。', + retry: '重试', + refresh: '刷新', + empty: '还没有安装任何插件。', + addPlugin: '添加插件', + restartBanner: '以下更改会在下次启动生效:{names}', + restartNotice: '更改会在下次启动生效。', + doneNotice: '已完成。', + dismiss: '知道了', + globalTitle: '全局插件', + globalSubtitle: '按包安装与启停;启用的 bundle 对系统与所有会话生效', + presetTitle: '会话插件', + presetSubtitle: '按 Agent 预设组成;这里的改动只写入该预设的用户补丁层', + switcherLabel: '选择要管理的 Agent 预设', + presetOptionDefault: '{name}(默认)', + presetOptionBroken: '{name}(加载失败)', + presetRowsEmpty: '该预设没有组合任何插件。', + presetNoRoster: '本部署没有 Agent 预设。', + countUnit: '个', + builtinTag: '内置', + externalTag: '第三方', + bundleTag: 'Bundle', + pluginTag: '插件模块', + libraryTag: '库', + stageBootTag: '启动阶段', + statusRunning: '运行中', + statusPartial: '部分运行', + statusFailed: '启动失败', + statusDisabled: '已停用', + statusNotEnableable: '无法启用', + statusRestartRequired: '待重启', + statusPlain: '按行添加', + enableToggle: '启用 {name}', + builtinLocked: '内置 bundle 随产品启动,不能在这里停用。', + expand: '展开 {name}', + collapse: '收起 {name}', + versionLabel: '版本', + packageLabel: '包名', + reasonLabel: '原因', + rowsLabel: '行', + rowsEmpty: '这个包没有向宿主树贡献任何行。', + overridesLabel: '覆盖的内置行', + addableLabel: '可添加的模块', + addableNotOk: '无法导入', + enginesLabel: '要求的 dsh 版本', + cordisLabel: 'Cordis 副本', + cordisSame: '与宿主相同', + cordisForeign: '另一份副本', + cordisUnknown: '未知', + probedAtLabel: '探测时间', + addTo: '添加到…', + addToGlobal: '全局', + addToPreset: '预设:{name}', + retryPackage: '重试', + uninstall: '卸载', + rowPhaseUnobserved: '未运行', + rowPhasePending: '等待依赖', + rowPhaseLoading: '加载中', + rowPhaseActive: '运行中', + rowPhaseFailed: '启动失败', + rowPhaseUnloading: '卸载中', + rowDisabledByUser: '用户停用', + rowDisabledByComposition: '组合停用', + rowSourceUser: '用户添加', + rowSourcePreset: '预设自带', + rowConditional: '条件启用', + rowToggle: '启用行 {id}', + rowRemove: '移除行 {id}', + rowNoId: '这一行没有声明 id,无法在这里启停。', + installTitle: '添加插件', + installDescription: '输入 npm 包名(可带版本)、本地目录路径或 git 地址;将以 pnpm 安装到当前 profile。', + installSpecLabel: '包名或路径', + installSpecPlaceholder: 'dsh-better-sidebar@latest 或 /path/to/plugin', + installEnable: '安装后立即启用(仅 bundle)', + installRun: '安装', + installRunning: '安装中…', + installLogLabel: '安装输出', + installDone: '安装完成:{names}', + installDoneNothing: '安装完成,没有新增依赖。', + installFailed: '安装失败。', + installClose: '完成', + close: '关闭', + cancel: '取消', + confirmUninstallTitle: '卸载 {name}', + confirmDisableTitle: '停用 {name}', + confirmUninstallDescription: '将从 profile 移除这个包及其行。', + confirmDisableDescription: '将把这个 bundle 移出启用列表。', + confirmDependents: '以下内容依赖它,停用后会进入等待状态:', + dependentService: '服务 {service}(由 {provider} 提供)被 {rows} 注入', + dependentReference: '{target} 中的行 {row} 引用了 {module}', + targetGlobal: '全局补丁层', + targetPreset: '预设 {name}', + acknowledge: '我了解影响,继续', + confirm: '继续', + actionFailed: '操作失败:{reason}', + notEnableable: '无法启用:{reason}', + enableFailed: '启用失败,已恢复停用:{reason}', + rowConflict: '目标层已存在 id 为 {row} 的行。', + notInstalled: '{name} 没有安装在当前 profile。', +} satisfies Record + +/** Plugin manager locale key union. */ +export type PluginManagerLocaleKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + tab: 'Manage plugins', + loading: 'Reading plugins…', + error: 'Plugins are temporarily unavailable.', + unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', + retry: 'Retry', + refresh: 'Refresh', + empty: 'No plugins are installed yet.', + addPlugin: 'Add plugin', + restartBanner: 'These changes take effect at the next start: {names}', + restartNotice: 'The change takes effect at the next start.', + doneNotice: 'Done.', + dismiss: 'Got it', + globalTitle: 'Global plugins', + globalSubtitle: 'Installed and switched per package; an enabled bundle serves the system and every session', + presetTitle: 'Session plugins', + presetSubtitle: 'Composed per agent preset; changes here land in that preset\'s user patch layer', + switcherLabel: 'Choose the agent preset to manage', + presetOptionDefault: '{name} (default)', + presetOptionBroken: '{name} (failed to load)', + presetRowsEmpty: 'This preset composes no plugins.', + presetNoRoster: 'This deployment has no agent presets.', + countUnit: 'packages', + builtinTag: 'Built-in', + externalTag: 'Third-party', + bundleTag: 'Bundle', + pluginTag: 'Plugin module', + libraryTag: 'Library', + stageBootTag: 'Boot stage', + statusRunning: 'Running', + statusPartial: 'Partly running', + statusFailed: 'Failed to start', + statusDisabled: 'Disabled', + statusNotEnableable: 'Cannot be enabled', + statusRestartRequired: 'Restart pending', + statusPlain: 'Added per row', + enableToggle: 'Enable {name}', + builtinLocked: 'A built-in bundle starts with the product and cannot be disabled here.', + expand: 'Show {name}', + collapse: 'Hide {name}', + versionLabel: 'Version', + packageLabel: 'Package', + reasonLabel: 'Reason', + rowsLabel: 'Rows', + rowsEmpty: 'This package contributes no rows to the host tree.', + overridesLabel: 'Overridden built-in rows', + addableLabel: 'Addable modules', + addableNotOk: 'Cannot be imported', + enginesLabel: 'Requires dsh', + cordisLabel: 'Cordis copy', + cordisSame: 'Same as the host', + cordisForeign: 'A separate copy', + cordisUnknown: 'Unknown', + probedAtLabel: 'Probed at', + addTo: 'Add to…', + addToGlobal: 'Global', + addToPreset: 'Preset: {name}', + retryPackage: 'Retry', + uninstall: 'Uninstall', + rowPhaseUnobserved: 'Not running', + rowPhasePending: 'Waiting for dependencies', + rowPhaseLoading: 'Loading', + rowPhaseActive: 'Running', + rowPhaseFailed: 'Failed to start', + rowPhaseUnloading: 'Unloading', + rowDisabledByUser: 'Disabled by the user', + rowDisabledByComposition: 'Disabled by the composition', + rowSourceUser: 'Added by the user', + rowSourcePreset: 'Preset composition', + rowConditional: 'Conditional', + rowToggle: 'Enable row {id}', + rowRemove: 'Remove row {id}', + rowNoId: 'This row declares no id, so it cannot be switched here.', + installTitle: 'Add plugin', + installDescription: 'Enter an npm package (optionally with a version), a local directory, or a git URL; pnpm installs it into the current profile.', + installSpecLabel: 'Package or path', + installSpecPlaceholder: 'dsh-better-sidebar@latest or /path/to/plugin', + installEnable: 'Enable right after installing (bundles only)', + installRun: 'Install', + installRunning: 'Installing…', + installLogLabel: 'Install output', + installDone: 'Installed: {names}', + installDoneNothing: 'Install finished with no new dependency.', + installFailed: 'The install failed.', + installClose: 'Done', + close: 'Close', + cancel: 'Cancel', + confirmUninstallTitle: 'Uninstall {name}', + confirmDisableTitle: 'Disable {name}', + confirmUninstallDescription: 'The package and its rows leave the profile.', + confirmDisableDescription: 'The bundle leaves the enabled list.', + confirmDependents: 'These depend on it and would wait once it is gone:', + dependentService: 'Service {service} (provided by {provider}) is injected by {rows}', + dependentReference: 'Row {row} in {target} names {module}', + targetGlobal: 'the global patch layer', + targetPreset: 'preset {name}', + acknowledge: 'I understand the impact, continue', + confirm: 'Continue', + actionFailed: 'The action failed: {reason}', + notEnableable: 'Cannot be enabled: {reason}', + enableFailed: 'Enabling failed and the bundle stays disabled: {reason}', + rowConflict: 'The target layer already has a row with id {row}.', + notInstalled: '{name} is not installed in the current profile.', +} satisfies Record diff --git a/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts new file mode 100644 index 0000000000..1fe537b9fd --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts @@ -0,0 +1,406 @@ +/** + * The plugin manager tab's state: the Host's package views and preset + * compositions, the action in flight, the install run, and the confirmation + * a destructive action waits on. Every fact comes from the Host — the store + * re-reads after each action and after every `plugins/changed` event, so a + * change made on another surface shows here without a manual refresh. + */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { + PluginDependents, PluginEnableResult, PluginInstallLogChunk, PluginInventorySnapshot, PluginPackageView, + PluginRowTarget, +} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' + +/** One preset composition as the inventory reports it, with its rows. */ +export type PresetGroup = NonNullable[number] + +/** One row of a preset composition. */ +export type PresetRow = PresetGroup['rows'][number] + +/** What the last action left to say. */ +export type ManagerNotice = + | { readonly kind: 'restart'; readonly packageName: string } + | { readonly kind: 'done'; readonly packageName?: string } + | { + readonly kind: 'failed' + /** The Host's failure code, which selects the copy. */ + readonly code: string + /** The Host's reason, shown verbatim. */ + readonly reason: string + readonly packageName?: string + readonly rowId?: string + } + +/** The install dialog. */ +export interface InstallState { + readonly open: boolean + /** The package spec as typed. */ + readonly spec: string + /** Whether a newly installed bundle is enabled right away. */ + readonly enable: boolean + readonly phase: 'idle' | 'running' | 'done' | 'failed' + /** pnpm's output so far, stdout and stderr interleaved as they arrived. */ + readonly log: string + /** Dependencies the last run added, once it finished. */ + readonly installed: readonly string[] +} + +/** A destructive action waiting for the user's acknowledgement. */ +export interface ConfirmState { + readonly action: 'uninstall' | 'disable' + readonly packageName: string + /** What the action would strand; undefined while the Host is asked. */ + readonly dependents: PluginDependents | undefined + readonly acknowledged: boolean +} + +/** What the tab renders. */ +export interface PluginManagerState { + /** `unavailable` when the Host runs without a profile runtime; `error` keeps the last packages. */ + readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable' + readonly packages: readonly PluginPackageView[] + readonly presets: readonly PresetGroup[] + /** The preset whose composition the session group shows; null picks the default. */ + readonly selectedPreset: string | null + /** Package names and row keys with an action crossing the wire. */ + readonly busy: readonly string[] + readonly notice: ManagerNotice | null + readonly install: InstallState + readonly confirm: ConfirmState | null +} + +/** The registration-side face the tab's slot entry injects. */ +export interface PluginManagerFace { + hooks: { + /** Tab snapshot bound by the renderer as usePluginManager. */ + pluginManager: SnapshotStore + } + /** Read the Host once the tab first renders. */ + ensure: () => void + /** Read the Host again. */ + refresh: () => void + openInstall: () => void + closeInstall: () => void + editInstallSpec: (text: string) => void + toggleInstallEnable: () => void + runInstall: () => void + /** Put a bundle into, or take it out of, the profile's layer list. */ + setEnabled: (packageName: string, enabled: boolean) => void + /** Compose an enabled bundle again from scratch. */ + retry: (packageName: string) => void + /** Ask before removing a package from the profile. */ + uninstall: (packageName: string) => void + acknowledgeConfirm: (acknowledged: boolean) => void + confirm: () => void + cancelConfirm: () => void + /** Add a row naming one of a package's modules — by its declared name, `.` for the main export — to a user layer. */ + addRow: (packageName: string, declaredName: string, target: PluginRowTarget) => void + removeRow: (target: PluginRowTarget, rowId: string) => void + setRowDisabled: (target: PluginRowTarget, rowId: string, disabled: boolean) => void + selectPreset: (id: string) => void + dismissNotice: () => void + /** Display name for one preset, resolved through the agent-preset dictionaries. */ + presetName: (preset: PresetGroup) => string +} + +/** A Remote answer as the generated client returns it. */ +type Answer = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details?: unknown } } + +/** One string field of a failure's details, when the details carry it. */ +function detailOf(error: { details?: unknown }, field: string): string | undefined { + const details = error.details + if (typeof details === 'object' && details !== null && field in details) { + const value = (details as Record)[field] + if (typeof value === 'string') return value + } + return undefined +} + +/** The Host reason a failure carries, when its details name one; else its message. */ +function reasonOf(error: { message: string; details?: unknown }): string { + return detailOf(error, 'reason') ?? error.message +} + +/** + * The key one row occupies in the busy list. + * @param target - the layer the row lives in. + * @param rowId - the row's id in that layer. + * @returns the busy key. + */ +export function rowKey(target: PluginRowTarget, rowId: string): string { + return `${target.kind === 'global' ? 'global' : `preset:${target.preset}`}:${rowId}` +} + +const IDLE_INSTALL: InstallState = { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] } + +/** Reads and mutates the profile's plugins through the `plugins` and `pluginInventory` Remotes. */ +export class PluginManagerController { + private readonly store: SnapshotStore + private inFlight: Promise | undefined + private rerun = false + private generation = 0 + private disposed = false + private pendingConfirm: (() => Promise) | undefined + + /** + * @param ctx - the tab plugin's context, whose `remote.plugins` and + * `remote.pluginInventory` namespaces answer. + * @param presetName - display name for one preset. + */ + constructor( + private readonly ctx: ClientContext, + private readonly presetName: (preset: PresetGroup) => string, + ) { + this.store = createSnapshotStore({ + status: 'idle', packages: [], presets: [], selectedPreset: null, busy: [], notice: null, + install: IDLE_INSTALL, confirm: null, + }) + } + + /** + * Read the tab's state. + * @returns the current sync snapshot (stable reference until the next change). + */ + getSnapshot(): PluginManagerState { + return this.store.getSnapshot() + } + + /** Stop publishing and drop every late settlement. */ + dispose(): void { + this.disposed = true + this.generation += 1 + } + + /** + * Build the face the tab's slot registration injects. + * @returns the tab's snapshot source and its actions. + */ + inject(): PluginManagerFace { + return { + hooks: { pluginManager: this.store }, + ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, + refresh: () => { void this.load() }, + openInstall: () => { this.patch({ install: { ...IDLE_INSTALL, open: true } }) }, + closeInstall: () => { + if (this.getSnapshot().install.phase === 'running') return + this.patch({ install: IDLE_INSTALL }) + }, + editInstallSpec: (text) => { this.patchInstall({ spec: text }) }, + toggleInstallEnable: () => { this.patchInstall({ enable: !this.getSnapshot().install.enable }) }, + runInstall: () => { void this.runInstall() }, + setEnabled: (packageName, enabled) => { void this.setEnabled(packageName, enabled) }, + retry: (packageName) => { + void this.run(packageName, { packageName }, async () => { + this.effect(await this.ctx.remote.plugins.retry(packageName), packageName) + }) + }, + uninstall: (packageName) => { void this.askConfirm('uninstall', packageName) }, + acknowledgeConfirm: (acknowledged) => { + const confirm = this.getSnapshot().confirm + if (confirm !== null) this.patch({ confirm: { ...confirm, acknowledged } }) + }, + confirm: () => { void this.confirm() }, + cancelConfirm: () => { this.pendingConfirm = undefined; this.patch({ confirm: null }) }, + addRow: (packageName, declaredName, target) => { + void this.run(packageName, { packageName }, async () => { + this.answer(await this.ctx.remote.plugins.addRow(packageName, target, { module: declaredName })) + this.patch({ notice: { kind: 'done', packageName } }) + }) + }, + removeRow: (target, rowId) => { + void this.run(rowKey(target, rowId), { rowId }, async () => { + this.answer(await this.ctx.remote.plugins.removeRow(target, rowId)) + }) + }, + setRowDisabled: (target, rowId, disabled) => { + void this.run(rowKey(target, rowId), { rowId }, async () => { + this.answer(await this.ctx.remote.plugins.setRowDisabled(target, rowId, disabled)) + }) + }, + selectPreset: (id) => { this.patch({ selectedPreset: id }) }, + dismissNotice: () => { this.patch({ notice: null }) }, + presetName: this.presetName, + } + } + + /** + * Fold one install-log chunk into the open run. A chunk for another spec — + * a CLI install running beside the page — is not this dialog's output. + * @param chunk - the chunk the Host forwarded. + */ + appendLog(chunk: PluginInstallLogChunk): void { + const install = this.getSnapshot().install + if (install.phase !== 'running' || chunk.spec !== install.spec.trim()) return + this.patchInstall({ log: install.log + chunk.text }) + } + + /** + * Read the packages and the preset compositions. A call during an + * in-flight read marks one rerun after it settles. + * @returns settlement after this call's freshness is reflected. + */ + load(): Promise { + if (this.disposed) return Promise.resolve() + if (this.inFlight !== undefined) { + this.rerun = true + return this.inFlight + } + const run = Promise.resolve().then(() => this.read()) + this.inFlight = run + return run + } + + private async read(): Promise { + try { + do { + this.rerun = false + const generation = ++this.generation + if (this.getSnapshot().status === 'idle') this.patch({ status: 'loading' }) + const [packages, inventory] = await Promise.all([ + this.ctx.remote.plugins.list(), + this.ctx.remote.pluginInventory.list(), + ]) + if (generation !== this.generation) return + if (!packages.ok) { + this.patch({ status: packages.error.code === 'plugins/unavailable' ? 'unavailable' : 'error' }) + continue + } + this.patch({ + status: 'ready', + packages: packages.value, + presets: inventory.ok ? inventory.value.agentPresets ?? [] : this.getSnapshot().presets, + }) + } while (this.shouldRerun()) + } finally { + this.inFlight = undefined + } + } + + private shouldRerun(): boolean { + return this.rerun + } + + private async setEnabled(packageName: string, enabled: boolean): Promise { + if (enabled) { + await this.run(packageName, { packageName }, async () => { + this.effect(await this.ctx.remote.plugins.enable(packageName), packageName) + }) + return + } + await this.askConfirm('disable', packageName) + } + + /** + * Open the confirmation for a destructive action, asking the Host what it + * would strand. A disable with nothing depending on the bundle needs no + * confirmation and runs at once. + */ + private async askConfirm(action: ConfirmState['action'], packageName: string): Promise { + const perform = action === 'uninstall' + ? async (): Promise => { + this.answer(await this.ctx.remote.plugins.uninstall(packageName)) + this.patch({ notice: { kind: 'done', packageName } }) + } + : async (): Promise => { + this.effect(await this.ctx.remote.plugins.disable(packageName), packageName) + } + this.pendingConfirm = () => this.run(packageName, { packageName }, perform) + this.patch({ confirm: { action, packageName, dependents: undefined, acknowledged: false } }) + const generation = this.generation + const dependents = await this.ctx.remote.plugins.dependents(packageName) + const confirm = this.getSnapshot().confirm + if (generation !== this.generation || confirm?.packageName !== packageName || confirm.action !== action) return + const value: PluginDependents = dependents.ok ? dependents.value : { services: [], references: [] } + if (action === 'disable' && value.services.length === 0 && value.references.length === 0) { + this.patch({ confirm: null }) + await this.confirm() + return + } + this.patch({ confirm: { ...confirm, dependents: value } }) + } + + private async confirm(): Promise { + const pending = this.pendingConfirm + this.pendingConfirm = undefined + this.patch({ confirm: null }) + if (pending !== undefined) await pending() + } + + private async runInstall(): Promise { + const install = this.getSnapshot().install + const spec = install.spec.trim() + if (install.phase === 'running' || spec === '') return + this.patchInstall({ phase: 'running', log: '', installed: [] }) + const generation = this.generation + const result = await this.ctx.remote.plugins.add(spec, { enable: install.enable }) + if (this.disposed || generation !== this.generation) return + if (result.ok) { + this.patchInstall({ phase: 'done', installed: result.value.installed }) + } else { + // Streamed chunks already show the run; otherwise the Host's captured + // log, or its message when pnpm never started. + const current = this.getSnapshot().install.log + const log = current === '' ? detailOf(result.error, 'log') ?? result.error.message : current + this.patchInstall({ phase: 'failed', log }) + } + void this.load() + } + + /** + * Run one action under a busy key, turn its failure into the notice, and + * re-read the Host afterwards whatever happened. + */ + private async run( + key: string, + subject: { packageName?: string; rowId?: string }, + action: () => Promise, + ): Promise { + if (this.disposed || this.getSnapshot().busy.includes(key)) return + this.patch({ busy: [...this.getSnapshot().busy, key], notice: null }) + try { + await action() + } catch (error) { + // `patch` drops the notice after disposal; a refused answer carries the + // Host's code, anything else is a transport failure. + const failure = error instanceof RemoteAnswerError + ? error + : new RemoteAnswerError('gateway/internal', error instanceof Error ? error.message : String(error)) + this.patch({ notice: { kind: 'failed', code: failure.code, reason: failure.reason, ...subject } }) + } finally { + this.patch({ busy: this.getSnapshot().busy.filter(entry => entry !== key) }) + } + await this.load() + } + + /** Publish an enable-shaped answer's effect as the notice. */ + private effect(result: Answer, packageName: string): void { + const value = this.answer(result) + this.patch({ notice: value.effect === 'restart' ? { kind: 'restart', packageName } : { kind: 'done', packageName } }) + } + + /** Unwrap an answer, throwing its failure for {@link run} to report. */ + private answer(result: Answer): T { + if (result.ok) return result.value + throw new RemoteAnswerError(result.error.code, reasonOf(result.error)) + } + + private patch(next: Partial): void { + if (this.disposed) return + this.store.set({ ...this.getSnapshot(), ...next }) + } + + private patchInstall(next: Partial): void { + this.patch({ install: { ...this.getSnapshot().install, ...next } }) + } +} + +/** A refused Remote answer, carried to the notice with the Host's code and reason. */ +class RemoteAnswerError extends Error { + constructor(readonly code: string, readonly reason: string) { + super(reason) + } +} diff --git a/packages/client/ui-settings-plugin-manager/src/css-modules.d.ts b/packages/client/ui-settings-plugin-manager/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-plugin-manager/src/index.ts b/packages/client/ui-settings-plugin-manager/src/index.ts new file mode 100644 index 0000000000..bcfd623c2a --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the plugin-manager tab's browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the plugin manager tab. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx new file mode 100644 index 0000000000..619d56e1d8 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject, NS } from '../src/client/index.ts' +import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx' +import type { PluginManagerFace } from '../src/client/manager-store.ts' +import { apply as hostApply } from '../src/index.ts' + +usePinnedBrowserLanguages('zh-CN') +afterEach(cleanup) + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + class LocaleHolder extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'localeHolder') + } + } + new LocaleHolder(ctx) + const list = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const inventory = vi.fn(() => Promise.resolve({ ok: true as const, value: { entries: [] } })) + const remote = new TestRemote(ctx, { + plugins: { list }, + pluginInventory: { list: inventory }, + }) + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, inventory, remote } +} + +function declare(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-plugin-manager browser plugin', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares only the services the tab and its two Remote faces use', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.plugins', 'remote.pluginInventory']) + }) + + it('registers a localized tab that reads the Host only once rendered, and follows Host changes', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + + const entry = b.slots.entries('settings.plugins.tab')[0]! + expect(entry.component).toBe(PluginManagerSettingsTab) + expect(entry.options).toMatchObject({ id: 'manage', order: 5 }) + expect(entry.locale).toBe(NS) + expect(resolveSlotLabel(entry.options.label)).toBe('插件管理') + expect(b.list).not.toHaveBeenCalled() + + const face = (entry.inject as unknown as () => PluginManagerFace)() + // A Host change before the first render is not a reason to read. + b.remote.emit('plugins/changed', [{ reason: 'install' }]) + b.ctx.emit('connection/reset') + await Promise.resolve() + expect(b.list).not.toHaveBeenCalled() + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + expect(b.list).toHaveBeenCalledTimes(1) + b.remote.emit('plugins/changed', [{ reason: 'enable', packageName: 'x' }]) + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + b.ctx.emit('connection/reset') + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) }) + + // Install output folds into an open run only. + face.openInstall() + face.editInstallSpec('pkg') + b.remote.emit('plugins/install-log', [{ jobId: 'j', spec: 'pkg', stream: 'stdout', text: 'early' }]) + expect(face.hooks.pluginManager.getSnapshot().install.log).toBe('') + + // Shipped preset names resolve over the agent-preset dictionaries the + // real plugin registers; user-authored metadata stays untranslated. + b.locale.register('settings.agentPreset', 'zh', { presetStandardName: '标准模式' } as never) + expect(face.presetName({ id: 'standard', trust: 'system', isDefault: true, rows: [] })).toBe('标准模式') + expect(face.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false, rows: [] })).toBe('我自己的') + + await fiber.dispose() + expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) + b.remote.emit('plugins/changed', [{ reason: 'install' }]) + await Promise.resolve() + expect(b.list).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx new file mode 100644 index 0000000000..63c52122c4 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx @@ -0,0 +1,394 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx' +import type { PluginManagerSettingsTabProps } from '../src/client/PluginManagerSettingsTab.tsx' +import type { PluginManagerState, PresetGroup } from '../src/client/manager-store.ts' +import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = ((key: PluginManagerLocaleKey, params?: Record): string => + Object.entries(params ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, value), + en[key], + )) as PluginManagerSettingsTabProps['t'] + +function pkg(overrides: Partial = {}): PluginPackageView { + return { + name: 'dsh-better-sidebar', + version: '0.16.0', + kind: 'bundle', + trust: 'external', + stage: 'runtime', + installed: true, + enabled: true, + status: 'running', + cordisSameCopy: true, + rows: [], + overrides: [], + addable: [], + liveReload: true, + ...overrides, + } +} + +function preset(overrides: Partial = {}): PresetGroup { + return { id: 'standard', trust: 'system', name: '标准', isDefault: true, rows: [], ...overrides } +} + +const READY: PluginManagerState = { + status: 'ready', + packages: [], + presets: [], + selectedPreset: null, + busy: [], + notice: null, + install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] }, + confirm: null, +} + +function renderTab(state: Partial = {}) { + const store = createSnapshotStore({ ...READY, ...state }) + const actions = { + ensure: vi.fn(), + refresh: vi.fn(), + openInstall: vi.fn(), + closeInstall: vi.fn(), + editInstallSpec: vi.fn(), + toggleInstallEnable: vi.fn(), + runInstall: vi.fn(), + setEnabled: vi.fn(), + retry: vi.fn(), + uninstall: vi.fn(), + acknowledgeConfirm: vi.fn(), + confirm: vi.fn(), + cancelConfirm: vi.fn(), + addRow: vi.fn(), + removeRow: vi.fn(), + setRowDisabled: vi.fn(), + selectPreset: vi.fn(), + dismissNotice: vi.fn(), + } + const props = { + t, + ...actions, + presetName: (candidate: PresetGroup) => candidate.name ?? candidate.id, + usePluginManager: bindSnapshotSelector(store), + } as unknown as PluginManagerSettingsTabProps + render() + return { store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } } +} + +describe('PluginManagerSettingsTab', () => { + it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { + const { actions, set } = renderTab({ status: 'loading' }) + expect(actions.ensure).toHaveBeenCalledTimes(1) + expect(screen.getByText(en.loading)).toBeTruthy() + expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true) + + set({ status: 'unavailable' }) + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + + set({ status: 'error' }) + expect(screen.getByRole('alert').textContent).toBe(en.error) + fireEvent.click(screen.getByRole('button', { name: en.retry })) + expect(actions.refresh).toHaveBeenCalledTimes(1) + expect(screen.getByText(en.empty)).toBeTruthy() + expect(screen.getByText(en.presetNoRoster)).toBeTruthy() + + set({ status: 'ready' }) + fireEvent.click(screen.getByRole('button', { name: en.refresh })) + fireEvent.click(screen.getByRole('button', { name: en.addPlugin })) + expect(actions.refresh).toHaveBeenCalledTimes(2) + expect(actions.openInstall).toHaveBeenCalledTimes(1) + }) + + it('lists packages with their tags and switches, and names what waits for a restart', () => { + const { version: _unversioned, ...firstPartyPackage } = pkg({ + name: '@deepseek-ai/dsh-bundle-first-party', title: 'First party', trust: 'builtin', stage: 'boot', + }) + const { actions } = renderTab({ + packages: [ + pkg(), + firstPartyPackage, + pkg({ name: 'broken-bundle', enabled: false, status: 'not-enableable', reason: 'foreign cordis' }), + pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain' }), + pkg({ name: 'some-lib', kind: 'library', status: 'plain' }), + pkg({ name: 'pending-bundle', title: 'Pending', enabled: true, status: 'restart-required' }), + pkg({ name: 'dsh-untitled', enabled: false, status: 'restart-required' }), + ], + }) + expect(screen.getByText(en.restartBanner.replace('{names}', 'Pending, untitled'))).toBeTruthy() + expect(document.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7') + expect(screen.getAllByText(en.externalTag)).toHaveLength(6) + expect(screen.getByText(en.builtinTag)).toBeTruthy() + expect(screen.getByText(en.stageBootTag)).toBeTruthy() + expect(screen.getByText(en.pluginTag)).toBeTruthy() + expect(screen.getByText(en.libraryTag)).toBeTruthy() + expect(screen.getByText(en.statusNotEnableable)).toBeTruthy() + expect(screen.getAllByText(en.statusPlain)).toHaveLength(2) + expect(screen.getByText('dsh-better-sidebar · 0.16.0')).toBeTruthy() + + const sidebar = screen.getByRole('switch', { name: 'Enable better-sidebar' }) as HTMLButtonElement + expect(sidebar.getAttribute('aria-checked')).toBe('true') + fireEvent.click(sidebar) + expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false) + const firstParty = screen.getByRole('switch', { name: 'Enable First party' }) as HTMLButtonElement + expect(firstParty.disabled).toBe(true) + expect(firstParty.title).toBe(en.builtinLocked) + expect(screen.getByRole('switch', { name: 'Enable broken-bundle' })).toHaveProperty('disabled', true) + // Plain packages carry no switch. + expect(screen.queryByRole('switch', { name: 'Enable tool-foo' })).toBeNull() + }) + + it('expands a package into its facts, rows, addable modules, and actions', () => { + const { actions, set } = renderTab({ + packages: [pkg({ + description: 'A sidebar.', + status: 'partial', + reason: 'one row failed', + enginesDsh: '>=0.1.0', + probedAt: '2026-09-04T00:00:00Z', + overrides: ['ui-sidebar'], + rows: [ + { entryId: 'dsh-better-sidebar/better-sidebar', originalId: 'better-sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active' }, + { entryId: 'dsh-better-sidebar/off', moduleName: 'dsh-better-sidebar/off', enabled: false, disabledBy: 'user', phase: null }, + { entryId: 'dsh-better-sidebar/gated', moduleName: 'dsh-better-sidebar/gated', enabled: false, disabledBy: 'composition', phase: null }, + { entryId: 'dsh-better-sidebar/crash', moduleName: 'dsh-better-sidebar/crash', enabled: true, phase: 'failed', failure: { stage: 'apply', message: 'boom' } }, + ], + addable: [ + { moduleName: 'dsh-better-sidebar/tool', declaredName: './tool', title: 'Sidebar tool', ok: true }, + { moduleName: 'dsh-better-sidebar/broken', declaredName: './broken', ok: false, error: 'cannot import' }, + ], + })], + presets: [preset()], + }) + const expand = screen.getByRole('button', { name: 'Show better-sidebar' }) + fireEvent.click(expand) + expect(screen.getByText('A sidebar.')).toBeTruthy() + expect(screen.getByText(`${en.reasonLabel}: one row failed`)).toBeTruthy() + expect(screen.getByText('>=0.1.0')).toBeTruthy() + expect(screen.getByText(en.cordisSame)).toBeTruthy() + expect(screen.getByText('2026-09-04T00:00:00Z')).toBeTruthy() + expect(screen.getByText('ui-sidebar')).toBeTruthy() + expect(screen.getByRole('img', { name: en.rowPhaseActive })).toBeTruthy() + // A prefixed row shows the id its bundle declared. + expect(document.querySelector('[data-plugin-row="dsh-better-sidebar/better-sidebar"]')?.textContent).toContain('better-sidebar') + expect(screen.getByText(en.rowDisabledByUser)).toBeTruthy() + expect(screen.getByText(en.rowDisabledByComposition)).toBeTruthy() + expect(screen.getByText('boom')).toBeTruthy() + expect(screen.getByText('Sidebar tool')).toBeTruthy() + expect(screen.getByText(en.addableNotOk)).toBeTruthy() + expect(screen.getByText('cannot import')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: en.addTo })) + fireEvent.click(screen.getByRole('menuitem', { name: en.addToGlobal })) + expect(actions.addRow).toHaveBeenCalledWith('dsh-better-sidebar', './tool', { kind: 'global' }) + fireEvent.click(screen.getByRole('button', { name: en.addTo })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Preset: 标准' })) + expect(actions.addRow).toHaveBeenLastCalledWith('dsh-better-sidebar', './tool', { kind: 'preset', preset: 'standard' }) + fireEvent.click(screen.getByRole('button', { name: en.addTo })) + fireEvent.click(screen.getByRole('button', { name: en.addTo })) + expect(screen.queryByRole('menuitem')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.addTo })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menuitem')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: en.retryPackage })) + expect(actions.retry).toHaveBeenCalledWith('dsh-better-sidebar') + fireEvent.click(screen.getByRole('button', { name: en.uninstall })) + expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar') + + // A busy package keeps its controls inert. + set({ busy: ['dsh-better-sidebar'] }) + expect(screen.getByRole('button', { name: en.retryPackage })).toHaveProperty('disabled', true) + expect(screen.getByRole('switch', { name: 'Enable better-sidebar' })).toHaveProperty('disabled', true) + + fireEvent.click(screen.getByRole('button', { name: 'Hide better-sidebar' })) + expect(screen.queryByText('A sidebar.')).toBeNull() + }) + + it('names an unknown or foreign cordis copy, an empty row list, and hides actions a package does not offer', () => { + renderTab({ + packages: [ + pkg({ name: 'foreign', cordisSameCopy: false, installed: false, status: 'disabled', enabled: false }), + pkg({ name: 'unknown', cordisSameCopy: null, trust: 'builtin', kind: 'plugin', status: 'plain' }), + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Show foreign' })) + expect(screen.getByText(en.cordisForeign)).toBeTruthy() + expect(screen.getByText(en.rowsEmpty)).toBeTruthy() + expect(screen.queryByRole('button', { name: en.uninstall })).toBeNull() + expect(screen.queryByRole('button', { name: en.retryPackage })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Show unknown' })) + expect(screen.getByText(en.cordisUnknown)).toBeTruthy() + expect(screen.queryByText(en.rowsLabel)).toBeNull() + }) + + it('renders the selected preset composition with row switches, removal, and the switcher', () => { + const rows: PresetGroup['rows'] = [ + { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: 'active', source: 'preset' }, + { entryId: 'extra', moduleName: '@fixture/extra', enabled: false, fiberPhase: null, source: 'user', disabledBy: 'user' }, + { entryId: 'gated', moduleName: '@fixture/gated', enabled: false, fiberPhase: null, source: 'preset', disabledBy: 'composition' }, + { entryId: 'pwsh', moduleName: '@fixture/pwsh', enabled: 'conditional', fiberPhase: null, source: 'preset' }, + { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed', source: 'preset' }, + { entryId: null, moduleName: '@fixture/anonymous', enabled: true, fiberPhase: null, source: 'preset' }, + ] + const { actions, set } = renderTab({ + presets: [preset({ rows }), preset({ id: 'research', trust: 'user', name: 'Research', isDefault: false })], + }) + const switcher = screen.getByRole('button', { name: en.switcherLabel }) + expect(switcher.textContent).toBe('标准 (default)') + expect(screen.getByRole('img', { name: en.rowPhaseActive })).toBeTruthy() + expect(screen.getByText(en.rowSourceUser)).toBeTruthy() + expect(screen.getByText(en.rowDisabledByUser)).toBeTruthy() + expect(screen.getByText(en.rowDisabledByComposition)).toBeTruthy() + expect(screen.getByText(en.rowConditional)).toBeTruthy() + expect(screen.getByText(en.rowPhaseFailed)).toBeTruthy() + expect(screen.getByText(en.rowNoId)).toBeTruthy() + expect(screen.queryByRole('switch', { name: 'Enable row anonymous' })).toBeNull() + + fireEvent.click(screen.getByRole('switch', { name: 'Enable row bash' })) + expect(actions.setRowDisabled).toHaveBeenCalledWith({ kind: 'preset', preset: 'standard' }, 'bash', true) + fireEvent.click(screen.getByRole('switch', { name: 'Enable row extra' })) + expect(actions.setRowDisabled).toHaveBeenLastCalledWith({ kind: 'preset', preset: 'standard' }, 'extra', false) + fireEvent.click(screen.getByRole('button', { name: 'Remove row extra' })) + expect(actions.removeRow).toHaveBeenCalledWith({ kind: 'preset', preset: 'standard' }, 'extra') + expect(screen.queryByRole('button', { name: 'Remove row bash' })).toBeNull() + + fireEvent.click(switcher) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menuitem')).toBeNull() + fireEvent.click(switcher) + fireEvent.click(screen.getByRole('menuitem', { name: 'Research' })) + expect(actions.selectPreset).toHaveBeenCalledWith('research') + set({ selectedPreset: 'research' }) + expect(screen.getByText(en.presetRowsEmpty)).toBeTruthy() + expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('Research') + + set({ busy: ['preset:standard:bash'], selectedPreset: 'standard' }) + expect(screen.getByRole('switch', { name: 'Enable row bash' })).toHaveProperty('disabled', true) + + set({ presets: [preset({ id: 'broken', broken: 'bad yaml', isDefault: false, rows })], selectedPreset: null }) + expect(screen.getByRole('alert').textContent).toBe('bad yaml') + expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('标准 (failed to load)') + }) + + it('shows each notice with the Host reason and dismisses it', () => { + const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x' } }) + expect(screen.getByRole('status').textContent).toContain(en.restartNotice) + fireEvent.click(screen.getByRole('button', { name: en.dismiss })) + expect(actions.dismissNotice).toHaveBeenCalledTimes(1) + set({ notice: { kind: 'done' } }) + expect(screen.getByRole('status').textContent).toContain(en.doneNotice) + set({ notice: { kind: 'failed', code: 'plugins/not-enableable', reason: 'foreign cordis', packageName: 'x' } }) + expect(screen.getByRole('alert').textContent).toContain('Cannot be enabled: foreign cordis') + set({ notice: { kind: 'failed', code: 'plugins/enable-failed', reason: 'tree rejected', packageName: 'x' } }) + expect(screen.getByRole('alert').textContent).toContain('tree rejected') + set({ notice: { kind: 'failed', code: 'plugins/row-conflict', reason: 'taken', rowId: 'r1' } }) + expect(screen.getByRole('alert').textContent).toContain('id r1') + set({ notice: { kind: 'failed', code: 'plugins/row-conflict', reason: 'taken' } }) + expect(screen.getByRole('alert').textContent).toContain('id .') + set({ notice: { kind: 'failed', code: 'plugins/not-installed', reason: 'missing', packageName: 'y' } }) + expect(screen.getByRole('alert').textContent).toContain('y is not installed') + set({ notice: { kind: 'failed', code: 'plugins/not-installed', reason: 'missing' } }) + expect(screen.getByRole('alert').textContent).toContain(' is not installed') + set({ notice: { kind: 'failed', code: 'gateway/internal', reason: 'offline' } }) + expect(screen.getByRole('alert').textContent).toContain('The action failed: offline') + }) + + it('drives the install dialog through its phases', () => { + const { actions, set } = renderTab({ install: { open: true, spec: '', enable: true, phase: 'idle', log: '', installed: [] } }) + const dialog = screen.getByRole('dialog', { name: en.installTitle }) + expect(dialog).toBeTruthy() + const spec = screen.getByLabelText(en.installSpecLabel) as HTMLInputElement + fireEvent.change(spec, { target: { value: 'pkg' } }) + expect(actions.editInstallSpec).toHaveBeenCalledWith('pkg') + fireEvent.click(screen.getByLabelText(en.installEnable)) + expect(actions.toggleInstallEnable).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: en.installRun })).toHaveProperty('disabled', true) + expect(screen.queryByLabelText(en.installLogLabel)).toBeNull() + + set({ install: { open: true, spec: 'pkg', enable: false, phase: 'idle', log: '', installed: [] } }) + fireEvent.click(screen.getByRole('button', { name: en.installRun })) + expect(actions.runInstall).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: en.cancel })) + expect(actions.closeInstall).toHaveBeenCalledTimes(1) + + set({ install: { open: true, spec: 'pkg', enable: false, phase: 'running', log: 'Progress', installed: [] } }) + expect(screen.getByRole('button', { name: en.installRunning })).toHaveProperty('disabled', true) + expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('Progress') + expect(screen.getByLabelText(en.installSpecLabel)).toHaveProperty('disabled', true) + + set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: 'Progress', installed: ['pkg'] } }) + expect(screen.getByRole('status').textContent).toBe('Installed: pkg') + set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [] } }) + expect(screen.getByRole('status').textContent).toBe(en.installDoneNothing) + fireEvent.click(screen.getByRole('button', { name: en.installClose })) + expect(actions.closeInstall).toHaveBeenCalledTimes(2) + + set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [] } }) + expect(screen.getByRole('alert').textContent).toBe(en.installFailed) + expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('ERR') + fireEvent.click(screen.getByRole('button', { name: en.close })) + expect(actions.closeInstall).toHaveBeenCalledTimes(3) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(actions.closeInstall).toHaveBeenCalledTimes(4) + + set({ install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] } }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('confirms a destructive action only once its dependents are known and acknowledged', () => { + const { actions, set } = renderTab({ + presets: [preset()], + confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar', dependents: undefined, acknowledged: false }, + }) + expect(screen.getByRole('dialog', { name: 'Uninstall better-sidebar' })).toBeTruthy() + expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy() + expect(screen.getByText(en.loading)).toBeTruthy() + expect(screen.getByRole('button', { name: en.confirm })).toHaveProperty('disabled', true) + + set({ + confirm: { + action: 'uninstall', + packageName: 'dsh-better-sidebar', + acknowledged: false, + dependents: { + services: [{ service: 'sidebar', providedBy: 'dsh-better-sidebar/better-sidebar', injectedBy: ['ui-x', 'ui-y'] }], + references: [ + { target: { kind: 'global' }, rowId: 'g1', moduleName: 'dsh-better-sidebar/tool' }, + { target: { kind: 'preset', preset: 'standard' }, rowId: 'p1', moduleName: 'dsh-better-sidebar/tool' }, + { target: { kind: 'preset', preset: 'gone' }, rowId: 'p2', moduleName: 'dsh-better-sidebar/tool' }, + ], + }, + }, + }) + expect(screen.getByText(en.confirmDependents)).toBeTruthy() + expect(screen.getByText('Service sidebar (provided by dsh-better-sidebar/better-sidebar) is injected by ui-x, ui-y')).toBeTruthy() + expect(screen.getByText('Row g1 in the global patch layer names dsh-better-sidebar/tool')).toBeTruthy() + expect(screen.getByText('Row p1 in preset 标准 names dsh-better-sidebar/tool')).toBeTruthy() + expect(screen.getByText('Row p2 in preset gone names dsh-better-sidebar/tool')).toBeTruthy() + expect(screen.getByRole('button', { name: en.confirm })).toHaveProperty('disabled', true) + fireEvent.click(screen.getByLabelText(en.acknowledge)) + expect(actions.acknowledgeConfirm).toHaveBeenCalledWith(true) + + set({ + confirm: { + action: 'disable', packageName: 'dsh-better-sidebar', acknowledged: true, + dependents: { services: [], references: [] }, + }, + }) + expect(screen.getByRole('dialog', { name: 'Disable better-sidebar' })).toBeTruthy() + expect(screen.getByText(en.confirmDisableDescription)).toBeTruthy() + expect(screen.queryByText(en.confirmDependents)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.confirm })) + expect(actions.confirm).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: en.cancel })) + expect(actions.cancelConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts new file mode 100644 index 0000000000..c07248d914 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts @@ -0,0 +1,359 @@ +/** + * The manager store: what it reads, how actions cross the wire, which + * failures become notices, and how the install run folds its output. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { PluginManagerController, rowKey, type PresetGroup } from '../src/client/manager-store.ts' + +const BUNDLE: PluginPackageView = { + name: 'dsh-better-sidebar', + version: '0.16.0', + kind: 'bundle', + trust: 'external', + stage: 'runtime', + installed: true, + enabled: false, + status: 'disabled', + cordisSameCopy: true, + rows: [], + overrides: [], + addable: [], + liveReload: true, +} + +const STANDARD: PresetGroup = { + id: 'standard', + trust: 'system', + isDefault: true, + rows: [{ entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null, source: 'preset' }], +} + +/** What one install run answers. */ +type InstallValue = { installed: string[]; enabled: never[]; installedOnly: never[]; plain: never[]; jobId: string } + +function ok(value: T) { + return { ok: true as const, value } +} + +function refused(code: string, message: string, details: object = {}) { + // The double's code map is keyed by literal codes; a spec-chosen string stands in. + return { ok: false as const, error: new RemoteError(code as never, message, details as never) } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { resolve = res }) + return { promise, resolve } +} + +function bench(overrides: Partial>> = {}) { + const plugins = { + list: vi.fn(() => Promise.resolve(ok([BUNDLE]))), + add: vi.fn(() => Promise.resolve(ok({ installed: ['a'], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))), + uninstall: vi.fn(() => Promise.resolve(ok(undefined))), + enable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))), + disable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'restart' }))), + retry: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))), + addRow: vi.fn(() => Promise.resolve(ok({ target: { kind: 'global' }, rowId: 'r', file: '/f' }))), + removeRow: vi.fn(() => Promise.resolve(ok(undefined))), + setRowDisabled: vi.fn(() => Promise.resolve(ok(undefined))), + dependents: vi.fn(() => Promise.resolve(ok({ services: [], references: [] }))), + ...overrides, + } + const inventory = { list: vi.fn(() => Promise.resolve(ok({ entries: [], agentPresets: [STANDARD] }))) } + const ctx = { remote: { plugins, pluginInventory: inventory } } as never + const controller = new PluginManagerController(ctx, preset => preset.name ?? preset.id) + const face = controller.inject() + return { plugins, inventory, controller, face, state: () => controller.getSnapshot() } +} + +describe('PluginManagerController', () => { + it('starts idle, reads packages and presets on first use, and folds concurrent loads', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench({ list: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(ok([BUNDLE])) }) + expect(state().status).toBe('idle') + face.ensure() + face.ensure() + await Promise.resolve() + expect(state().status).toBe('loading') + const mid = controller.load() + gate.resolve(ok([])) + await mid + // The in-flight read reran once for the load that landed mid-read. + expect(plugins.list).toHaveBeenCalledTimes(2) + expect(state()).toMatchObject({ status: 'ready', packages: [BUNDLE], presets: [STANDARD] }) + face.ensure() + expect(plugins.list).toHaveBeenCalledTimes(2) + face.refresh() + await controller.load() + expect(plugins.list).toHaveBeenCalledTimes(3) + expect(face.presetName(STANDARD)).toBe('standard') + }) + + it('reports an unavailable profile runtime and keeps the last packages across a failed read', async () => { + const { plugins, controller, state } = bench({ + list: vi.fn() + .mockResolvedValueOnce(refused('plugins/unavailable', 'no profile', { reason: 'no profile' })) + .mockResolvedValueOnce(ok([BUNDLE])) + .mockResolvedValueOnce(refused('gateway/internal', 'boom')), + }) + await controller.load() + expect(state().status).toBe('unavailable') + await controller.load() + expect(state()).toMatchObject({ status: 'ready', packages: [BUNDLE] }) + await controller.load() + expect(state()).toMatchObject({ status: 'error', packages: [BUNDLE] }) + expect(plugins.list).toHaveBeenCalledTimes(3) + }) + + it('keeps the held presets when the inventory read is refused', async () => { + const { inventory, controller, state } = bench() + await controller.load() + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'boom') as never) + await controller.load() + expect(state().presets).toEqual([STANDARD]) + }) + + it('enables a bundle, marks it busy meanwhile, and says when a restart is needed', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench({ enable: vi.fn().mockReturnValueOnce(gate.promise) }) + await controller.load() + face.setEnabled(BUNDLE.name, true) + face.setEnabled(BUNDLE.name, true) + expect(state().busy).toEqual([BUNDLE.name]) + expect(plugins.enable).toHaveBeenCalledTimes(1) + gate.resolve(ok({ changed: true, effect: 'restart' })) + await vi.waitFor(() => { expect(state().busy).toEqual([]) }) + expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name }) + expect(plugins.list).toHaveBeenCalledTimes(2) + face.dismissNotice() + expect(state().notice).toBeNull() + }) + + it('turns a refused enable into a notice carrying the Host reason, or its message without one', async () => { + const { face, state, controller } = bench({ + enable: vi.fn() + .mockResolvedValueOnce(refused('plugins/not-enableable', 'plugin-manager: x cannot', { reason: 'foreign cordis' })) + .mockResolvedValueOnce(refused('plugins/not-enableable', 'no string reason', { reason: 42 })), + }) + await controller.load() + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).not.toBeNull() }) + expect(state().notice).toEqual({ + kind: 'failed', code: 'plugins/not-enableable', reason: 'foreign cordis', packageName: BUNDLE.name, + }) + expect(state().busy).toEqual([]) + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).toMatchObject({ reason: 'no string reason' }) }) + }) + + it('disables at once when nothing depends on the bundle, and asks first when something does', async () => { + const { plugins, face, state, controller } = bench() + await controller.load() + face.setEnabled(BUNDLE.name, false) + await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledWith(BUNDLE.name) }) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name }) }) + + plugins.dependents.mockResolvedValueOnce(ok({ + services: [{ service: 'sidebar', providedBy: 'dsh-better-sidebar/better-sidebar', injectedBy: ['x'] }], + references: [], + }) as never) + face.setEnabled(BUNDLE.name, false) + expect(state().confirm).toEqual({ action: 'disable', packageName: BUNDLE.name, dependents: undefined, acknowledged: false }) + await vi.waitFor(() => { expect(state().confirm?.dependents).toBeDefined() }) + face.acknowledgeConfirm(true) + expect(state().confirm?.acknowledged).toBe(true) + face.confirm() + expect(state().confirm).toBeNull() + await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledTimes(2) }) + }) + + it('always asks before uninstalling, and cancelling runs nothing', async () => { + const { plugins, face, state, controller } = bench() + await controller.load() + face.uninstall(BUNDLE.name) + await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [], references: [] }) }) + face.cancelConfirm() + expect(state().confirm).toBeNull() + face.confirm() + await Promise.resolve() + expect(plugins.uninstall).not.toHaveBeenCalled() + + face.uninstall(BUNDLE.name) + await vi.waitFor(() => { expect(state().confirm?.dependents).toBeDefined() }) + face.acknowledgeConfirm(true) + face.confirm() + await vi.waitFor(() => { expect(plugins.uninstall).toHaveBeenCalledWith(BUNDLE.name) }) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'done', packageName: BUNDLE.name }) }) + // An acknowledgement with no confirmation open is ignored. + face.acknowledgeConfirm(true) + expect(state().confirm).toBeNull() + }) + + it('drops a dependents answer that arrives after the confirmation changed', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench({ dependents: vi.fn().mockReturnValueOnce(gate.promise) }) + await controller.load() + face.uninstall(BUNDLE.name) + face.cancelConfirm() + gate.resolve(ok({ services: [], references: [] })) + await Promise.resolve() + await Promise.resolve() + expect(state().confirm).toBeNull() + // A refused dependents read confirms with an empty list rather than blocking. + plugins.dependents.mockResolvedValueOnce(refused('gateway/internal', 'boom') as never) + face.uninstall(BUNDLE.name) + await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [], references: [] }) }) + }) + + it('retries, adds rows, removes rows, and switches rows under their own busy keys', async () => { + const { plugins, face, state, controller } = bench() + await controller.load() + face.retry(BUNDLE.name) + await vi.waitFor(() => { expect(plugins.retry).toHaveBeenCalledWith(BUNDLE.name) }) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'done', packageName: BUNDLE.name }) }) + + face.addRow('@fixture/tool', '.', { kind: 'preset', preset: 'standard' }) + await vi.waitFor(() => { + expect(plugins.addRow).toHaveBeenCalledWith('@fixture/tool', { kind: 'preset', preset: 'standard' }, { module: '.' }) + }) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'done', packageName: '@fixture/tool' }) }) + + const target = { kind: 'preset', preset: 'standard' } as const + face.setRowDisabled(target, 'bash', true) + expect(state().busy).toEqual([rowKey(target, 'bash')]) + await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith(target, 'bash', true) }) + await vi.waitFor(() => { expect(state().busy).toEqual([]) }) + face.removeRow(target, 'extra') + await vi.waitFor(() => { expect(plugins.removeRow).toHaveBeenCalledWith(target, 'extra') }) + await vi.waitFor(() => { expect(state().busy).toEqual([]) }) + face.selectPreset('research') + expect(state().selectedPreset).toBe('research') + }) + + it('reports a row conflict with the row id and a thrown transport failure generically', async () => { + const { face, state, controller } = bench({ + addRow: vi.fn(() => Promise.resolve(refused('plugins/row-conflict', 'taken', { rowId: 'r', target: { kind: 'global' } }))), + removeRow: vi.fn(() => Promise.reject(new Error('offline'))), + // A rejection that is not an Error reaches the notice by its string form. + setRowDisabled: vi.fn().mockRejectedValueOnce('plain text'), + }) + await controller.load() + face.addRow('p', 'p', { kind: 'global' }) + await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed' }) }) + expect(state().notice).toEqual({ kind: 'failed', code: 'plugins/row-conflict', reason: 'taken', packageName: 'p' }) + face.removeRow({ kind: 'global' }, 'r') + await vi.waitFor(() => { + expect(state().notice).toEqual({ kind: 'failed', code: 'gateway/internal', reason: 'offline', rowId: 'r' }) + }) + face.setRowDisabled({ kind: 'global' }, 'r', true) + await vi.waitFor(() => { expect(state().notice).toMatchObject({ reason: 'plain text' }) }) + }) + + it('runs an install, folds its own log chunks, and closes only once it settled', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench({ add: vi.fn().mockReturnValueOnce(gate.promise) }) + await controller.load() + face.runInstall() + expect(plugins.add).not.toHaveBeenCalled() + face.openInstall() + expect(state().install).toMatchObject({ open: true, spec: '', enable: true, phase: 'idle' }) + face.editInstallSpec(' dsh-better-sidebar ') + face.toggleInstallEnable() + face.runInstall() + face.runInstall() + expect(plugins.add).toHaveBeenCalledTimes(1) + expect(plugins.add).toHaveBeenCalledWith('dsh-better-sidebar', { enable: false }) + expect(state().install.phase).toBe('running') + face.closeInstall() + expect(state().install.open).toBe(true) + controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Progress\n' }) + controller.appendLog({ jobId: 'j2', spec: 'other', stream: 'stdout', text: 'not mine' }) + expect(state().install.log).toBe('Progress\n') + gate.resolve(ok({ installed: ['dsh-better-sidebar'], enabled: [], installedOnly: [], plain: [], jobId: 'j1' })) + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(state().install.installed).toEqual(['dsh-better-sidebar']) + controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'late', exitCode: 0 }) + expect(state().install.log).toBe('Progress\n') + await vi.waitFor(() => { expect(plugins.list).toHaveBeenCalledTimes(2) }) + face.closeInstall() + expect(state().install.open).toBe(false) + }) + + it('shows the Host log of a failed install, or its message when no chunk arrived', async () => { + const { face, state, controller, plugins } = bench({ + add: vi.fn() + .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ERR_PNPM' })) + .mockResolvedValueOnce(refused('gateway/internal', 'offline')) + .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ignored' })), + }) + await controller.load() + face.openInstall() + face.editInstallSpec('x') + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.log).toBe('ERR_PNPM') + face.runInstall() + await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(2) }) + await vi.waitFor(() => { expect(state().install.log).toBe('offline') }) + face.runInstall() + controller.appendLog({ jobId: 'j', spec: 'x', stream: 'stderr', text: 'streamed' }) + await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.log).toBe('streamed') + }) + + it('drops every late settlement after disposal', async () => { + const enableGate = deferred>>() + const installGate = deferred>>() + const { face, state, controller } = bench({ + enable: vi.fn().mockReturnValueOnce(enableGate.promise), + add: vi.fn().mockReturnValueOnce(installGate.promise), + }) + await controller.load() + face.openInstall() + face.editInstallSpec('x') + face.runInstall() + face.setEnabled(BUNDLE.name, true) + const before = state() + controller.dispose() + enableGate.resolve(ok({ changed: true, effect: 'live' })) + installGate.resolve(ok({ installed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j' })) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(state()).toBe(before) + await controller.load() + expect(state()).toBe(before) + face.setEnabled(BUNDLE.name, false) + expect(state()).toBe(before) + }) + + it('drops a read that settles after disposal', async () => { + const gate = deferred>>() + const { state, controller } = bench({ list: vi.fn().mockReturnValueOnce(gate.promise) }) + const loading = controller.load() + await Promise.resolve() + const before = state() + controller.dispose() + gate.resolve(ok([BUNDLE])) + await loading + expect(state()).toBe(before) + }) + + it('reports a thrown enable failure after disposal to nobody', async () => { + const enableGate = deferred() + const { face, state, controller } = bench({ enable: vi.fn().mockReturnValueOnce(enableGate.promise) }) + await controller.load() + face.setEnabled(BUNDLE.name, true) + const before = state() + controller.dispose() + enableGate.resolve(refused('gateway/internal', 'late') as never) + await Promise.resolve() + await Promise.resolve() + expect(state()).toBe(before) + }) +}) diff --git a/packages/client/ui-settings-plugin-manager/tsconfig.json b/packages/client/ui-settings-plugin-manager/tsconfig.json new file mode 100644 index 0000000000..5daa7347e1 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../api/remotes/tsconfig.client.json" }, + { "path": "../../host/plugin-manager" }, + { "path": "../../preset/agent-presets" }, + { "path": "../../test-support/client-runtime" }, + { "path": "../locale" }, + { "path": "../store" }, + { "path": "../ui-agent-preset" }, + { "path": "../ui-primitives" }, + { "path": "../ui-renderer" }, + { "path": "../ui-settings" }, + { "path": "../ui-settings-plugins" }, + { "path": "../ui-slots" } + ] +} diff --git a/packages/client/ui-settings-plugin-manager/tsdown.config.ts b/packages/client/ui-settings-plugin-manager/tsdown.config.ts new file mode 100644 index 0000000000..8d6182cbd4 --- /dev/null +++ b/packages/client/ui-settings-plugin-manager/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugin-manager', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index a6a1e613fd..8bbc05275c 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/README.i18n.yaml @@ -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/client/ui-settings-plugins/README.md -README.md: 444b05b16b79a3a660a71984c92969d367fe22a7 -README.zh.md: c05859fc9f12ce0fe2013dc9f3549e156bd7234f +README.md: 032dc5489011973556b7945f45d88898255e0026 +README.zh.md: 7d6879a96f34a563607e87fe6a2b4fefd3041cd7 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 444b05b16b..032dc54890 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-settings-plugins` is the **Plugins** settings section of the dsh web client: users edit host-plane plugin configuration on its **Plugin configuration** tab, and feature plugins contribute their own pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns: a card shows the plugin's name and what it governs, and expanding it reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. Cards stage edits locally and write only on save, with every write fenced by the namespace revision the form read. +`dsh-client-ui-settings-plugins` is the **Plugins** settings section of the dsh web client: users edit host-plane plugin configuration on its **Plugin configuration** tab, and feature plugins contribute their own pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns: a card shows the plugin's name and what it governs, and expanding it reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. One switch above the cards chooses the scope every card edits — the values shared by all agent presets, or one preset's own — and under a preset a field marks whether it inherits the shared value. Cards stage edits locally per scope and write only on save, with every write fenced by the namespace revision the form read. ## Table of Contents @@ -25,7 +25,11 @@ English | [中文](README.zh.md) ## Use this package -Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`). +Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), the DeepSeek search provider (`web-search-deepseek`), and the filesystem skill provider's extra roots (`skill-filesystem`). + +### Choosing the scope + +The **Applies to** switch above the cards selects the settings scope every card edits: **All presets** is the global instance of each namespace, and each agent preset in the roster is that preset's named scope (`preset/`). Under a preset, a field the preset does not override shows **Inherited** when the shared user layer carries it, a reset stages the inherited value rather than the composition default, and a card whose plugin the preset does not compose says so — its values are stored and take effect once a preset composes the plugin. Drafts belong to the scope they were typed under and survive a switch. A roster the Host refuses leaves the global instance editable and says the presets could not be listed. ### What appears here diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index c05859fc9f..7d6879a96f 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-plugins` 是 dsh Web 客户端的**插件**设置分区:用户在其**插件配置**标签页上编辑宿主平面插件配置,功能插件则通过 `settings.plugins.tab` 贡献自己的页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片:卡片展示插件名称及其管辖范围,展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。卡片暂存用户输入,只有用户保存时才写入,且每次写入都以表单读取时的命名空间 revision 设栅。 +`dsh-client-ui-settings-plugins` 是 dsh Web 客户端的**插件**设置分区:用户在其**插件配置**标签页上编辑宿主平面插件配置,功能插件则通过 `settings.plugins.tab` 贡献自己的页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片:卡片展示插件名称及其管辖范围,展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。卡片上方的一个开关选择每张卡片编辑的范围——所有 Agent 预设共用的值,或某个预设自己的值——在预设之下,字段会标注它是否继承共用值。卡片按范围暂存用户输入,只有用户保存时才写入,且每次写入都以表单读取时的命名空间 revision 设栅。 ## 目录 @@ -25,7 +25,11 @@ kind: "package-reference" ## 使用本包 -打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)、DeepSeek 搜索提供方(`web-search-deepseek`)以及文件系统技能提供方的额外目录(`skill-filesystem`)。 + +### 选择生效范围 + +卡片上方的**生效范围**开关选择每张卡片编辑的 settings scope:**所有预设**是每个命名空间的全局实例,roster 中的每个 Agent 预设则是该预设的具名 scope(`preset/`)。在某个预设之下,预设未覆盖而共用用户层携带的字段显示**继承**,重置暂存的是继承值而不是组合默认值,插件未被该预设组合的卡片会说明这一点——它的值会被保存,并在某个预设组合该插件后生效。草稿属于输入它时所在的范围,切换后仍然保留。宿主拒绝 roster 时全局实例仍可编辑,并提示无法列出预设。 ### 这里会出现什么 diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index ef75045009..75c77a484c 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -30,7 +30,8 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-agent-preset" ], "platform": "web" } @@ -45,16 +46,18 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx b/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx index cb310af0cd..e043923f19 100644 --- a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx @@ -34,6 +34,7 @@ export function AgentLoopCard(props: AgentLoopCardProps) { label={t('agentLoopMaxParallel')} hint={t('agentLoopMaxParallelHint')} overriddenLabel={t('overridden')} + inheritedLabel={t('inherited')} resetLabel={t('reset')} invalidLabel={t('invalidNumber')} numeric diff --git a/packages/client/ui-settings-plugins/src/client/BashCard.tsx b/packages/client/ui-settings-plugins/src/client/BashCard.tsx index ff130769a0..7a7bb3af9f 100644 --- a/packages/client/ui-settings-plugins/src/client/BashCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/BashCard.tsx @@ -35,6 +35,7 @@ export function BashCard(props: BashCardProps) { label={t('bashTimeoutMs')} hint={t('bashTimeoutMsHint')} overriddenLabel={t('overridden')} + inheritedLabel={t('inherited')} resetLabel={t('reset')} invalidLabel={t('invalidNumber')} numeric @@ -48,6 +49,7 @@ export function BashCard(props: BashCardProps) { label={t('bashMaxOutputBytes')} hint={t('bashMaxOutputBytesHint')} overriddenLabel={t('overridden')} + inheritedLabel={t('inherited')} resetLabel={t('reset')} invalidLabel={t('invalidNumber')} numeric diff --git a/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx b/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx index c9b4365a3a..f3f37c2040 100644 --- a/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx +++ b/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx @@ -4,40 +4,102 @@ * The tab enumerates settings namespaces but never interprets one — a card * arrives through `settings.plugin.item` keyed by the namespace it edits, so a * plugin that ships a browser half owns its own card and this tab only decides - * which keys to dispatch. + * which keys to dispatch. Above the cards, one switch chooses the scope every + * card edits: the values shared by all agent presets, or one preset's own. */ -import { Fragment } from 'react' +import { Fragment, useEffect, useState } from 'react' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from './slot-contract.ts' import type { ConfigurablePluginsTabFace } from './tab-store.ts' +import { PRESET_SCOPE_PREFIX, type PresetScopeRow, type ScopeSwitcherFace } from './scope-switcher.ts' import css from './PluginsSettingsSection.module.css' +/** Menu id of the global instance; a scope id never collides with it, as every scope carries a `/`. */ +const GLOBAL_OPTION = 'global' + /** Props the renderer binds for the configurable tab. */ export type ConfigurablePluginsTabProps = PropsRuntime<'settings.plugins.tab'> & PropsLocale<'settings.plugins'> & PropsRenderSlots<'settings.plugin.item'> - & InjectFace + & InjectFace /** - * Render cards registered by plugins that expose editable settings. - * @param props - locale copy, slot rendering, and the namespaces to dispatch. - * @returns the card list, or the empty line once the Host has answered. + * Render the scope switch and the cards registered by plugins that expose + * editable settings. + * @param props - locale copy, slot rendering, the namespaces to dispatch, and the scope switch. + * @returns the switch and card list, or the empty line once the Host has answered. */ export function ConfigurablePluginsTab(props: ConfigurablePluginsTabProps) { - const { t, renderSlot } = props + const { t, renderSlot, ensureScopes, selectScope, presetName } = props const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot) - if (namespaces.length > 0) { - return ( -
      - {namespaces.map(ns => ( - // One dispatch per namespace, so the list identity is the namespace - // rather than a position that shifts as cards arrive. - {renderSlot('settings.plugin.item', {}, { entryKey: ns })} - ))} -
    - ) + const switcher = props.useScopeSwitcher(snapshot => snapshot) + const [open, setOpen] = useState(false) + useEffect(() => { ensureScopes() }, [ensureScopes]) + + const presetLabel = (preset: PresetScopeRow): string => { + const name = presetName(preset) + if (preset.broken !== undefined) return t('scopePresetBroken', { name }) + if (preset.isDefault) return t('scopePresetDefault', { name }) + return name } - return loaded ?

    {t('empty')}

    : null + const items = [ + { id: GLOBAL_OPTION, label: t('scopeGlobal') }, + ...switcher.presets.map(preset => ({ id: `${PRESET_SCOPE_PREFIX}${preset.id}`, label: presetLabel(preset) })), + ...switcher.extraScopes.map(scope => ({ id: scope, label: scope })), + ] + const selectedId = switcher.scope ?? GLOBAL_OPTION + const selected = items.find(item => item.id === selectedId) + // A selected scope the options no longer list keeps its raw id visible. + const selectedLabel = selected?.label ?? selectedId + + return ( +
    +
    + {t('scopeLabel')} + { setOpen(false) }} + items={items} + selectedId={selectedId} + onSelect={(id) => { + setOpen(false) + selectScope(id === GLOBAL_OPTION ? null : id) + }} + align="end" + portal + anchor={( + + )} + /> +
    +

    + {switcher.status === 'error' ? t('scopeRosterFailed') : t(switcher.scope === undefined ? 'scopeHintGlobal' : 'scopeHintPreset')} +

    + {namespaces.length > 0 + ? ( +
      + {namespaces.map(ns => ( + // One dispatch per namespace, so the list identity is the namespace + // rather than a position that shifts as cards arrive. + {renderSlot('settings.plugin.item', {}, { entryKey: ns })} + ))} +
    + ) + : loaded ?

    {t('empty')}

    : null} +
    + ) } diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx index 472a9f0e85..ca22b3c8a7 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx @@ -82,6 +82,9 @@ export function PluginCard(props: PluginCardProps) { ? (
    {!state.writable ?

    {props.t('readOnly')}

    : null} + {state.scope !== undefined && !state.registered + ?

    {props.t('notMounted')}

    + : null} {props.children}
    {state.failed ?

    {props.t('saveFailed')}

    : null} diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index 0bd1beb9bf..0f45b6a6a3 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -83,3 +83,68 @@ font-size: 13px; color: var(--dsw-alias-label-tertiary); } + +/* Configurable tab: the scope switch above the card list. */ + +.configurable { + display: flex; + flex-direction: column; + gap: 10px; +} + +.scopeRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.scopeLabel { + font-size: 13px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.scopeSwitcher { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 320px; + height: 30px; + padding: 0 10px 0 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 999px; + corner-shape: round; + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.scopeSwitcher:hover { + border-color: var(--dsw-alias-label-dimmed); +} + +.scopeSwitcher:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + +.scopeSwitcherLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scopeChevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.scopeHint { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-settings-plugins/src/client/SkillFilesystemCard.tsx b/packages/client/ui-settings-plugins/src/client/SkillFilesystemCard.tsx new file mode 100644 index 0000000000..5eb21a21c3 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SkillFilesystemCard.tsx @@ -0,0 +1,48 @@ +/** The filesystem skill provider's card: the extra roots the agent discovers skills in. */ + +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { ValueField } from './fields.tsx' +import { PluginCard } from './PluginCard.tsx' +import type { SkillFilesystemCardFace } from './skill-filesystem-card-controller.ts' +import type {} from './slot-contract.ts' + +/** Props the renderer binds for the skill roots card. */ +export type SkillFilesystemCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.plugins'> + & InjectFace + +/** + * Render the skill roots card. + * @param props - locale copy, the card snapshot, and its form actions. + * @returns the card. + */ +export function SkillFilesystemCard(props: SkillFilesystemCardProps) { + const { t } = props + const state = props.useSkillFilesystemCard(snapshot => snapshot) + return ( + + { props.edit('customSkillDirs', text) }} + onReset={() => { props.resetField('customSkillDirs') }} + /> + + ) +} diff --git a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx b/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx index f1991b7629..939c8c8f56 100644 --- a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx @@ -53,6 +53,7 @@ export function WebSearchCard(props: WebSearchCardProps) { label={t('webSearchBaseUrl')} hint={t('webSearchBaseUrlHint')} overriddenLabel={t('overridden')} + inheritedLabel={t('inherited')} resetLabel={t('reset')} invalidLabel={t('invalidNumber')} disabled={disabled} @@ -65,6 +66,7 @@ export function WebSearchCard(props: WebSearchCardProps) { label={t('webSearchMaxUses')} hint={t('webSearchMaxUsesHint')} overriddenLabel={t('overridden')} + inheritedLabel={t('inherited')} resetLabel={t('reset')} invalidLabel={t('invalidNumber')} numeric diff --git a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts b/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts index d37d59b394..8da655a097 100644 --- a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts @@ -1,8 +1,8 @@ /** The agent-loop card's staged form over the `agent-loop` settings namespace. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' +import { numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' +import { ScopedCardForms, type BindScope, type ScopeSelection } from './scoped-form.ts' /** * Namespace of the agent loop's user-owned settings. Spelled here rather than @@ -33,14 +33,17 @@ export interface AgentLoopCardFace extends CardActions { } } -/** Bridges the `agent-loop` scope onto the card's staged form. */ +/** Bridges the `agent-loop` namespace, under the selected scope, onto the card's staged form. */ export class AgentLoopCardController { - private readonly form: CardForm + private readonly form: ScopedCardForms private readonly store: SnapshotStore - /** @param scope - the bound settings scope for the `agent-loop` namespace. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [numberField('maxParallelToolCalls')]) + /** + * @param selection - the scope selection shared with the tab. + * @param bindScope - binds the `agent-loop` namespace under one scope. + */ + constructor(selection: ScopeSelection, bindScope: BindScope) { + this.form = new ScopedCardForms(selection, bindScope, [numberField('maxParallelToolCalls')]) this.store = this.form.bind(() => this.projection()) } diff --git a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts index 3ad8c6fefe..4565e3d908 100644 --- a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts @@ -1,8 +1,8 @@ /** The shell card's staged form over the `bash` settings namespace. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' +import { numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' +import { ScopedCardForms, type BindScope, type ScopeSelection } from './scoped-form.ts' /** * Namespace of the shell capability. Spelled here rather than imported: a @@ -35,14 +35,17 @@ export interface BashCardFace extends CardActions { } } -/** Bridges the `bash` scope onto the shell card's staged form. */ +/** Bridges the `bash` namespace, under the selected scope, onto the shell card's staged form. */ export class BashCardController { - private readonly form: CardForm + private readonly form: ScopedCardForms private readonly store: SnapshotStore - /** @param scope - the bound settings scope for the `bash` namespace. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')]) + /** + * @param selection - the scope selection shared with the tab. + * @param bindScope - binds the `bash` namespace under one scope. + */ + constructor(selection: ScopeSelection, bindScope: BindScope) { + this.form = new ScopedCardForms(selection, bindScope, [numberField('timeoutMs'), numberField('maxOutputBytes')]) this.store = this.form.bind(() => this.projection()) } diff --git a/packages/client/ui-settings-plugins/src/client/card-form.ts b/packages/client/ui-settings-plugins/src/client/card-form.ts index 365a2e8fd8..517bb5b693 100644 --- a/packages/client/ui-settings-plugins/src/client/card-form.ts +++ b/packages/client/ui-settings-plugins/src/client/card-form.ts @@ -56,6 +56,12 @@ export interface CardFieldState { * reporting a state the pending edit already contradicts. */ overridden: boolean + /** + * Under a named scope, whether the field's value comes from the GLOBAL user + * layer rather than this scope's own: not overridden here, overridden + * there. Always false for the global instance. + */ + inherited: boolean /** Whether the draft is not a value this field accepts, which blocks saving. */ invalid: boolean } @@ -64,6 +70,14 @@ export interface CardFieldState { export interface CardShell { /** False while the namespace is not served to this client; the card renders nothing. */ available: boolean + /** The named scope the form edits, such as `preset/`; undefined for the global instance. */ + scope: string | undefined + /** + * Whether a live Host plugin registered the namespace under the form's + * scope. False under a named scope whose composition does not mount the + * plugin: edits are stored and take effect once one does. + */ + registered: boolean /** Whether the Host document accepts writes. */ writable: boolean /** Whether the form holds edits that a save would write. */ @@ -145,6 +159,25 @@ export function textField(field: string): CardFieldSpec { } } +/** + * A list of lines. Each non-blank line is one entry; blank lines are dropped + * and every line is trimmed. An empty draft clears the field. + * @param field - field name inside the namespace section. + * @returns the field's conversion spec. + */ +export function linesField(field: string): CardFieldSpec { + return { + field, + format: value => Array.isArray(value) + ? value.flatMap(entry => typeof entry === 'string' ? [entry] : []).join('\n') + : '', + parse: (text) => { + const lines = text.split('\n').map(line => line.trim()).filter(line => line !== '') + return lines.length === 0 ? { kind: 'clear' } : { kind: 'set', value: lines } + }, + } +} + /** * Stages one card's edits over one settings namespace and writes them on save. * @@ -175,6 +208,24 @@ export class CardForm { scope.subscribe(() => { this.publish() }) } + /** + * The settings scope this form stages over, for reads and writes outside the staged fields. + * @returns the bound settings scope. + */ + scopeOf(): SettingsScope { + return this.scope + } + + /** + * Observe the form: the scope moved or a draft changed. + * @param listener - invoked after each change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + /** * Publish a projection of this form, rebuilt whenever the scope or a draft changes. * @param project - build the card's state from the form's current reads. @@ -182,7 +233,7 @@ export class CardForm { */ bind(project: () => S): SnapshotStore { const store = createSnapshotStore(project()) - this.listeners.add(() => { store.set(project()) }) + this.subscribe(() => { store.set(project()) }) return store } @@ -195,6 +246,8 @@ export class CardForm { const plan = this.plan() return { available: snapshot.status === 'ready', + scope: snapshot.scope, + registered: snapshot.registered, writable: snapshot.writable, dirty: plan.length > 0, invalid: plan.some(item => item.run === undefined), @@ -211,20 +264,33 @@ export class CardForm { field(field: string): CardFieldState { const staged = this.staged.get(field) if (this.secretSpecs.has(field)) { - return { text: staged?.text ?? '', overridden: false, invalid: false } + return { text: staged?.text ?? '', overridden: false, inherited: false, invalid: false } } const spec = this.spec(field) if (staged === undefined) { - return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false } + return { + text: spec.format(this.sectionValue(field)), overridden: this.stored(field), inherited: false, invalid: false, + } } const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text) return { text: staged.text, overridden: write?.kind === 'set', + inherited: false, invalid: write === undefined, } } + /** + * Whether the form's user layer carries the field — for a named scope, that + * scope's own section. Presence, not value, is what marks an override. + * @param field - field name inside the namespace section. + * @returns whether the layer holds an entry for it. + */ + hasOverride(field: string): boolean { + return this.stored(field) + } + /** * Build the edit, reset, save, and discard actions bound to this form. * @returns the actions a card's slot entry injects. @@ -233,7 +299,7 @@ export class CardForm { return { edit: (field, text) => { this.stage(field, { text, clear: false }) }, resetField: (field) => { - this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true }) + this.stage(field, { text: this.spec(field).format(this.fallbackValue(field)), clear: true }) }, save: () => { void this.save() }, discard: () => { @@ -307,7 +373,11 @@ export class CardForm { private async store(field: string, value: unknown): Promise { await this.scope.set(field, value) - return this.userLayer()?.[field] === value + const stored = this.userLayer()?.[field] + // A list lands as a copy; compare it by content. + return Array.isArray(value) && Array.isArray(stored) + ? value.length === stored.length && value.every((entry, index) => entry === stored[index]) + : stored === value } private stage(field: string, edit: StagedEdit): void { @@ -332,8 +402,14 @@ export class CardForm { return (this.snapshotOf().value as Record | undefined)?.[field] } - private baseValue(field: string): unknown { - return (this.snapshotOf().base as Record | undefined)?.[field] + /** + * What the field reverts to once cleared: under a named scope the value the + * scope inherits (global user layer over composition), else the composition layer. + */ + private fallbackValue(field: string): unknown { + const snapshot = this.snapshotOf() + const layer = (snapshot.inherited ?? snapshot.base) as Record | undefined + return layer?.[field] } private userLayer(): Record | undefined { diff --git a/packages/client/ui-settings-plugins/src/client/fields.module.css b/packages/client/ui-settings-plugins/src/client/fields.module.css index b0ab9eaef9..8656590361 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.module.css +++ b/packages/client/ui-settings-plugins/src/client/fields.module.css @@ -100,6 +100,34 @@ border-color: var(--dsw-alias-label-error); } +.textarea { + min-height: 72px; + padding: 8px 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 8px; + background: var(--dsw-alias-bg-layer-3); + font: inherit; + font-size: 13px; + line-height: 1.5; + color: var(--dsw-alias-label-primary); + resize: vertical; +} + +.textarea:focus-visible { + outline: none; + border-color: var(--dsw-alias-brand-primary); +} + +.textarea:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +.textareaInvalid { + composes: textarea; + border-color: var(--dsw-alias-label-error); +} + .invalid { margin: 0; font-size: 12px; diff --git a/packages/client/ui-settings-plugins/src/client/fields.tsx b/packages/client/ui-settings-plugins/src/client/fields.tsx index a30998ddad..a82f28d519 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.tsx +++ b/packages/client/ui-settings-plugins/src/client/fields.tsx @@ -20,10 +20,14 @@ export interface FieldProps { text: string /** True when saving would leave a user-layer entry for this field. */ overridden: boolean + /** True when a named scope takes this field's value from the global user layer. */ + inherited: boolean /** True when the draft is not a value this field accepts. */ invalid: boolean /** Copy for the overridden badge. */ overriddenLabel: string + /** Copy for the inherited badge. */ + inheritedLabel: string /** Copy for the reset control. */ resetLabel: string /** Copy shown in place of the hint while the draft is invalid. */ @@ -39,7 +43,7 @@ export interface FieldProps { /** * A staged value field. `numeric` only hints the keypad: which drafts a field * accepts is decided by its spec, so the control never silently rewrites what - * the user typed. + * the user typed. `multiline` renders a text area for line-list fields. * @param props - the field's copy, its staged text, and the edit actions. * @returns the labelled control. */ @@ -48,7 +52,10 @@ export function ValueField(props: FieldProps & { numeric?: boolean /** Placeholder shown while the draft is empty. */ placeholder?: string + /** Render a text area: the draft holds one entry per line. */ + multiline?: boolean }) { + const invalidProps = props.invalid ? { 'aria-invalid': true as const } : {} return (
    @@ -67,19 +74,36 @@ export function ValueField(props: FieldProps & { ) - : null} + : props.inherited + ? {props.inheritedLabel} + : null}
    - { props.onEdit(event.target.value) }} - /> + {props.multiline === true + ? ( +