diff --git a/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.i18n.yaml b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.i18n.yaml new file mode 100644 index 0000000000..e8956e9545 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.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/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 diff --git a/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md new file mode 100644 index 0000000000..18e3ce19da --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md @@ -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: 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:`); 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. diff --git a/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.zh.md b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.zh.md new file mode 100644 index 0000000000..4726f39a04 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.zh.md @@ -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: disabled: true|false`。状态**始终显式写入**——重新启用写 +`disabled: false`,因为删掉该行会回落到 bundle 自身的 `disabled` 默认,而不是用户的选择。 + +补丁行的 id 用条目的裸 `options.id`,而非带组前缀的 Loader 树 id +(`include:`);两者不同,只有裸 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 路径,并在最后应用的用户层写显式覆盖。 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index a91c02df5e..15adbca483 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -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. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 261db0b92c..9cc418a1a9 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -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 都没有)的行无法从这里切换。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afc7b0a121..a9bb1c25b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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