docs(plugin-inventory): document setEnabled, persistence, and the in-page toggle decision

Update the plugin-inventory READMEs from read-only to toggleable and record
the in-page enable/disable capability as an Agent Note.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 13:53:46 +08:00
parent f01795ea97
commit 842483164d
6 changed files with 150 additions and 6 deletions
@@ -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/feature/2026-08-14-plugin-enable-disable-in-page.md
2026-08-14-plugin-enable-disable-in-page.md: 18e3ce19da7e23f10a9fd9a79bbbcc907378065a
2026-08-14-plugin-enable-disable-in-page.zh.md: 4726f39a04d8157759f2d67553f04d749c601ba7
@@ -0,0 +1,76 @@
# Agent Note: In-page plugin enable/disable
Status: implemented
English | [中文](2026-08-14-plugin-enable-disable-in-page.zh.md)
## Problem
The Web UI's plugin list was read-only: it showed the Loader's entries and their
lifecycle state but could not toggle a plugin on or off. Users wanted to
enable/disable plugins from the page, with the choice surviving a restart.
## Decision
Extend `PluginInventoryGateway` (`packages/host/plugin-inventory`) from a
read-only projection to one that also enables/disables. It now publishes a
second direct Remote, `pluginInventory/setEnabled(entryId, enabled)`, which:
1. Calls `ctx.loader.update(entryId, { disabled: !enabled })``Entry.update`
disposes or re-starts the plugin's fiber live (the same runtime path HMR's
config refresh uses).
2. Persists an explicit `disabled` override into the profile's user patch layer
(`cordis.patch.yml`) so the choice survives a restart.
The persistence is written via `persistPluginDisabled` (`src/persist.ts`): it
upserts `- id: <rowId> disabled: true|false` atomically. The state is **always
written explicitly** — re-enabling writes `disabled: false`, because dropping
the row would fall back to the bundle's own `disabled` default rather than the
user's choice.
The patch row id is the entry's bare `options.id`, not the group-prefixed Loader
tree id (`include:<rowId>`); the two differ and only the bare id matches the
patch's `applyEntryPatches` target lookup.
The Web plugin-list tab (`ui-settings-plugin-inventory`) adds an enable/disable
button to each expanded card, wired to the Remote, re-listing after the toggle.
## Persistence caveat
A runtime toggle alone does not survive a restart for a row enabled by a bundle
patch, because `Entry.update` writes the fully-patched tree and the patch layer
re-applies on the next read. Writing the override into the profile's
`cordis.patch.yml` (the last-applied user layer) is what makes it durable. The
web profile has HMR off, so the immediate live effect comes from
`loader.update`, not from the file write; the file matters only on restart.
## Verification
- `persistPluginDisabled` unit tests: append, override-a-bundle-disable, and
dedupe an existing override.
- `PluginInventoryGateway.setEnabled` test: toggles the Loader entry live.
- `remoteMethods` includes `setEnabled`.
- Real use: toggle a plugin in the plugin-list tab, confirm its fiber phase
changes and the profile `cordis.patch.yml` carries the override; restart and
confirm the choice holds.
## Alternatives considered
- **Runtime-only toggle (no persistence).** Rejected: the user asked for the
choice to survive a restart, which a bare `loader.update` cannot guarantee for
a bundle-patch-disabled row.
- **Persist by writing the fully-patched tree to the base config.** Rejected:
the profile root config is an empty entry list; dumping the whole composed
tree there would corrupt it. The durable write must target the user patch
layer instead.
## Consequences
- **Costs:** the gateway grows from read-only to writable, adding a Remote and a
profile-patch write path; toggling a row the profile does not mount (absent
from every bundle) is unsupported; re-enabling always writes `disabled: false`
so the patch carries a row even for a default-enabled plugin the user turned
back on.
- **Buys:** users can enable/disable plugins from the page with the choice
surviving a restart, using the Loader's existing runtime update path plus an
explicit override in the last-applied user layer.
@@ -0,0 +1,59 @@
# Agent Note:页面内开关插件
Status: implemented
English | [中文](2026-08-14-plugin-enable-disable-in-page.md)
## 问题
Web UI 的插件列表是只读的:它展示 Loader 的条目和生命周期状态,但不能在页面内开关插件。
用户希望能在页面内启用/停用插件,且重启后保留选择。
## 决策
`PluginInventoryGateway``packages/host/plugin-inventory`)从只读投影扩展为可启停。
新增第二个直接 Remote `pluginInventory/setEnabled(entryId, enabled)`,它:
1. 调用 `ctx.loader.update(entryId, { disabled: !enabled })` —— `Entry.update`
实时 dispose/重启插件 fiber(与 HMR 配置刷新用的同一条运行时路径)。
2. 把显式 `disabled` 覆盖写进 profile 的用户补丁层(`cordis.patch.yml`),使选择在重启后保留。
持久化经 `persistPluginDisabled``src/persist.ts`)实现:原子 upsert
`- id: <rowId> disabled: true|false`。状态**始终显式写入**——重新启用写
`disabled: false`,因为删掉该行会回落到 bundle 自身的 `disabled` 默认,而不是用户的选择。
补丁行的 id 用条目的裸 `options.id`,而非带组前缀的 Loader 树 id
`include:<rowId>`);两者不同,只有裸 id 能命中 patch 的 `applyEntryPatches` 目标查找。
Web 插件列表 tab`ui-settings-plugin-inventory`)在每张展开卡片的详情区加启用/停用按钮,
绑定该 Remote,切换后重新拉取列表。
## 持久化注意
仅运行时 toggle 对由 bundle patch 启用的行不持久,因为 `Entry.update` 写回的是补丁后的整棵树,
而 patch 层在下一次读取时会重放。把覆盖写进 profile 的 `cordis.patch.yml`(最后应用的用户层)
才是持久的。web profile 的 HMR 关闭,所以立即生效来自 `loader.update`,而非文件写入;
文件只在重启时起作用。
## 验证
- `persistPluginDisabled` 单测:追加、覆盖 bundle 默认禁用、去重已有覆盖。
- `PluginInventoryGateway.setEnabled` 测试:实时切换 Loader 条目。
- `remoteMethods` 包含 `setEnabled`
- 实机:在插件列表 tab 切换某插件,确认其 fiber phase 变化且 profile
`cordis.patch.yml` 带上覆盖;重启后确认选择仍保留。
## 备选方案
- **仅运行时 toggle(不持久)。** 已否决:用户要求选择在重启后保留,而裸的
`loader.update` 对 bundle patch 禁用的行无法保证这一点。
- **把补丁后的整棵树写回 base config 以持久化。** 已否决:profile 根配置是空条目列表,
把整棵组合树倒进去会破坏它。持久写必须落到用户补丁层。
## 后果
- **代价:** 网关从只读变为可写,新增一个 Remote 和一条写 profile patch 的路径;
对 profile 未挂载(任何 bundle 都没有)的行不支持切换;重新启用总是写
`disabled: false`,所以即使默认启用的插件被用户重新打开,patch 也会带一行。
- **收益:** 用户可在页面内开关插件,且选择在重启后保留——复用 Loader 现有的运行时
update 路径,并在最后应用的用户层写显式覆盖。
+3 -3
View File
@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Read-only Host projection of the current Cordis Loader tree. `PluginInventoryGateway` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase.
Host projection of the current Cordis Loader tree with per-plugin enable/disable. `PluginInventoryGateway` registers the `pluginInventory` service and publishes two generated direct Remotes: `pluginInventory/list` and `pluginInventory/setEnabled`. `list` reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase.
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, or event stream. `setEnabled` toggles one entry live through `ctx.loader.update` and persists an explicit `disabled` override into the profile's user patch layer so the choice survives a restart (a bundle-default disable needs the `disabled: false` override to stick). Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation.
@@ -19,4 +19,4 @@ None; this package never assembles model input.
## Known Limitations and Deferred Work
- **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.
- **No provenance or add/remove** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot add or remove plugins. Enable/disable persists to the profile's user patch layer; a row the profile does not mount (absent from every bundle) cannot be toggled from here.
+3 -3
View File
@@ -2,9 +2,9 @@
[English](README.md) | 中文
当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryGateway` 注册 `pluginInventory` 服务,并发布个由 Typert 生成的直接 Remote`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。
当前 Cordis Loader 树的 Host 投影,带逐插件启用/停用`PluginInventoryGateway` 注册 `pluginInventory` 服务,并发布个由 Typert 生成的直接 Remote`pluginInventory/list``pluginInventory/setEnabled``list` 直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。
阶段为 `pending``loading``active``failed``unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型事件流或修改路径。公开 payload 类型位于 `./types`Typert 生成由 `./typert``./remote` 导出的 Host 和 Client Remote 产物。
阶段为 `pending``loading``active``failed``unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型事件流`setEnabled` 通过 `ctx.loader.update` 实时切换单条条目,并把显式 `disabled` 覆盖写进 profile 的用户补丁层,使选择在重启后保留(bundle 默认禁用的行需要 `disabled: false` 覆盖才能保持启用)。公开 payload 类型位于 `./types`Typert 生成由 `./typert``./remote` 导出的 Host 和 Client Remote 产物。
该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。
@@ -19,4 +19,4 @@
## 已知限制与暂缓事项
- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。
- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。
- **无来源、不能增删** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能添加或移除插件。启用/停用持久化到 profile 的用户补丁层;profile 未挂载(任何 bundle 都没有)的行无法从这里切换。
+3
View File
@@ -4959,6 +4959,9 @@ importers:
packages/host/plugin-inventory:
dependencies:
js-yaml:
specifier: ^4.2.0
version: 4.2.0
zod:
specifier: ^4.4.3
version: 4.4.3