Просмотр исходного кода

docs(ui-settings): document the describe mirror and its budget

Yichen Jiang 2 месяцев назад
Родитель
Сommit
4e4dc8a9e5

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.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-08-17-settings-describe-mirror.md
+2026-08-17-settings-describe-mirror.md: c845c8ab65c526f27d09d1efdfd584a757ef00ba
+2026-08-17-settings-describe-mirror.zh.md: 1229aaf9900178b6a86d7f3cdedeebc2ac99663c

+ 32 - 0
.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md

@@ -0,0 +1,32 @@
+# Agent Note: Settings describe mirror
+
+Status: implemented
+
+English | [中文](2026-08-17-settings-describe-mirror.zh.md)
+
+## Problem
+
+A cold web boot issued `settings.describe` fifteen times inside ~200ms, and the count grew by two with every client plugin that owned a preference. Two mechanisms stacked: `SettingsScopeBinder.bind()` started a full-document read per bound scope (six scopes in the product composition, plus the plugin-directory tab, the welcome gate, and the models onboarding join), and `onConnected` emits `connection/reset` on the FIRST connection too, so every one of those readers immediately re-read the answer it had fetched milliseconds earlier. Each reader also carried its own invalidation subscriptions and its own `refreshIfLoaded`-style guard, and fifteen independent reads could in principle land on fifteen different document revisions.
+
+## Decision
+
+**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun — the in-flight slot clears inside the run's own try/finally, in the same synchronous segment that observes the rerun flag, because a `.finally()` on the returned promise runs one microtask later and a `load()` landing in that gap marked a rerun nobody read.
+
+`bind()` still returns the unchanged `SettingsScope<T>` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; a failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace SCHEMA, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, a read-only face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`).
+
+The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budget.e2e.ts`: the mirror's eager bind-time read, plus the first-connection reset read, which is kept deliberately — it closes the window where a document commit lands between the eager HTTP read and the SSE subscription and its invalidation is lost. The plan's original target of one read is unreachable without either accepting that lost-invalidation window or delaying the first read until after the SSE stream opens.
+
+## Alternatives considered
+
+- **Single-flight sharing inside `bind()` only** — deduplicates the concurrent bursts but keeps N direct readers, N subscription sets, and the revision skew; readers outside the binder (welcome, models, tab, permission) gain nothing. Rejected as treating the symptom.
+- **Boot-payload embedding** (host inlines the describe answer into the page boot) — saves the first read but adds a second acquisition path with its own staleness rules on top of the mirror it would still need. Deferred; it composes with the mirror if ever wanted.
+- **Per-namespace `settings.describe(ns)`** — shrinks each answer but keeps one read per consumer, so the fan-out and the growth rate stay. Rejected.
+- **One read (no first-reset re-read)** — reachable only by accepting the lost-invalidation window between the eager HTTP read and the SSE subscription, or by delaying the first read until the stream opens; both trade correctness or first-paint freshness for one loopback request. Rejected in favor of the pinned two.
+
+## Consequences
+
+- Startup `settings.describe` went 15 → 2, and a new preference-owning plugin adds zero reads.
+- Every derived surface shows the same document revision at any moment; the per-reader guards (`refreshWelcomeIfLoaded`, `refreshPermissionIfLoaded`, `refreshDocumentIfLoaded`) and their subscriptions are gone.
+- The mirror refreshes on every document commit regardless of namespace, so an external settings edit now costs one background read even while no settings surface is open — the price of surfaces that open already fresh. The per-namespace `ns !== spec.namespace` filters are gone with the per-scope subscriptions.
+- `credentials.describe` (3 startup calls), `agentPreset.list` (2), and `llm.providers` are separate sources and stay direct; the same mirror pattern fits them if they ever need it.
+- A new direct `settings.describe` caller in client code is a budget regression; the e2e's failure message says to grep for callers outside `ui-settings`.

+ 32 - 0
.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md

@@ -0,0 +1,32 @@
+# Agent Note:Settings describe 镜像
+
+Status: implemented
+
+[English](2026-08-17-settings-describe-mirror.md) | 中文
+
+## 问题
+
+一次冷启动的 web boot 在约 200ms 内发出十五次 `settings.describe`,且每新增一个持有偏好设置的客户端插件,该计数再加二。两个机制叠加:`SettingsScopeBinder.bind()` 为每个绑定的 scope 启动一次全量文档读取(产品组合中有六个 scope,外加插件目录 tab、welcome 门与 models onboarding join),而 `onConnected` 在**首次**连接时同样发出 `connection/reset`,于是上述每个读取方都立即重读了几毫秒前刚取到的应答。每个读取方还各自持有失效订阅与各自的 `refreshIfLoaded` 式防护,且十五次独立读取原则上可能落在十五个不同的文档 revision 上。
+
+## 决定
+
+**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读——在飞槽位在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空,因为挂在返回 promise 上的 `.finally()` 要晚一个微任务执行,落入该间隙的 `load()` 会标记一个无人读取的 rerun。
+
+`bind()` 返回的 `SettingsScope<T>` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 **schema** 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 只读面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。
+
+冷启动预算由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉在两次读取:镜像在绑定时的急切读取,加上首连 reset 触发的读取——后者是有意保留的:它关闭了「文档提交落在急切 HTTP 读取与 SSE 订阅之间、其失效通知丢失」的窗口。方案最初的一次读取目标,若不接受该失效丢失窗口、或不把首次读取推迟到 SSE 流建立之后,无法达成。
+
+## 考虑过的备选
+
+- **仅在 `bind()` 内做 single-flight 共享**——能去重并发风暴,但仍保留 N 个直连读取方、N 套订阅以及 revision 偏差;binder 之外的读取方(welcome、models、tab、permission)毫无受益。以治标为由否决。
+- **boot 载荷内嵌**(宿主把 describe 应答内联进页面 boot)——省下首次读取,却在镜像仍然需要的前提下增加第二条带自身陈旧规则的取数路径。推迟;若将来需要,它可与镜像叠加。
+- **按命名空间的 `settings.describe(ns)`**——缩小单次应答,但每个消费者仍各读一次,扇出与增长率原样保留。否决。
+- **一次读取(去掉首连 reset 重读)**——只有接受「急切 HTTP 读取与 SSE 订阅之间的失效丢失窗口」、或把首次读取推迟到流建立之后才可达成;两者都在用正确性或首屏新鲜度换一次环回请求。否决,保留钉住的两次。
+
+## 后果
+
+- 启动期 `settings.describe` 从 15 次降到 2 次,新增持有偏好设置的插件带来零次新增读取。
+- 任一时刻每个派生面看到的都是同一份文档 revision;各读取方的防护(`refreshWelcomeIfLoaded`、`refreshPermissionIfLoaded`、`refreshDocumentIfLoaded`)及其订阅随之消失。
+- 镜像对任何命名空间的文档提交都会刷新,因此在没有任何设置表面打开时,一次外部设置编辑现在也花费一次后台读取——这是「表面打开即新鲜」的代价。随着各 scope 订阅的删除,按命名空间的 `ns !== spec.namespace` 过滤一并消失。
+- `credentials.describe`(启动 3 次)、`agentPreset.list`(2 次)与 `llm.providers` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。
+- 客户端代码中新增直连 `settings.describe` 调用即是预算回归;e2e 的失败信息会提示在 `ui-settings` 之外 grep 调用方。

+ 2 - 2
packages/client/ui-settings/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
-README.md: 950585c4957cd59fe3a38dc37cdd4084f7c5541c
-README.zh.md: dce8dbf5c8e0939142fed8a3df84c47acd7c8a1e
+README.md: 4c5519677ace060a7378c22581dce3dbcb8b3495
+README.zh.md: b3565355d88f54278d97b56557dcbe626503f121

+ 1 - 1
packages/client/ui-settings/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 
 The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
 The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
 
 
-The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one.
+The plugin injects `connection` and `remote` and owns the one `settings.describe` reader in the browser: a shared mirror holding the whole answer, refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included — that read closes the window where a commit lands between the eager read and the SSE subscription). `ctx.settingsScope.bind(spec)` returns a per-namespace scope DERIVED from the mirror on the CALLER's context — the scope's disposer belongs to the calling fiber, binding adds no wire read, a row's activation never blocks on the settings transport, and every derived surface shows the same document revision at any moment. Cross-namespace surfaces (schema introspection, the served-namespace directory, `hasDocument`) read the same mirror through `ctx.settingsScope.describe()`, a read-only face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). Writes stay per-scope: one field path fenced by the namespace revision as `expectedRevision`; a committed write folds its answer back into the mirror with no re-read, a rejected or failed latest write triggers one mirror recovery read, and a superseded one leaves recovery to its successor. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one. The cold-boot read count is pinned by `apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
 
 
 ## Model Experience
 ## Model Experience
 
 

+ 1 - 1
packages/client/ui-settings/README.zh.md

@@ -4,7 +4,7 @@
 
 
 设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
 设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
 
 
-该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。
+该插件注入 `connection` 与 `remote`,并持有浏览器中唯一的 `settings.describe` 读取方:一面持有完整应答的共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内——这次读取关闭了「提交落在急切读取与 SSE 订阅之间、其失效通知丢失」的窗口)。`ctx.settingsScope.bind(spec)` 在**调用方**的 context 上返回一个由镜像**派生**的按命名空间 scope——scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上,且任一时刻每个派生面看到的都是同一份文档 revision。跨命名空间的表面(schema 内省、已服务命名空间目录、`hasDocument`)通过 `ctx.settingsScope.describe()` 读同一面镜像,这是一个只读面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。写入仍归各 scope:单一字段路径,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入将应答折回镜像、不再重读,被拒绝或失败的最新写入触发一次镜像恢复读取,被取代的写入则把恢复留给后继者。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。冷启动读取次数由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
 
 
 ## 模型体验
 ## 模型体验
 
 

+ 10 - 10
packages/client/ui-settings/src/client/settings-scope.ts

@@ -246,16 +246,6 @@ export class SettingsScopeBinder extends Service {
     this.mirror = config.mirror
     this.mirror = config.mirror
   }
   }
 
 
-  /**
-   * Bind one namespace scope on the CALLER's plugin lifecycle — the service
-   * proxy binds `this.ctx` to the caller at call time, so the scope's disposer
-   * belongs to the calling fiber. The scope derives from the shared mirror
-   * (whose invalidation subscriptions live with the providing plugin), so
-   * binding adds no wire read of its own and activation never blocks on the
-   * settings transport.
-   * @param spec - domain-owned namespace contract.
-   * @returns the bound scope consumed by the domain's services and rows.
-   */
   /**
   /**
    * The shared mirror's read-only face for cross-namespace surfaces (schema
    * The shared mirror's read-only face for cross-namespace surfaces (schema
    * introspection, the served-namespace directory). Per-namespace consumers
    * introspection, the served-namespace directory). Per-namespace consumers
@@ -267,6 +257,16 @@ export class SettingsScopeBinder extends Service {
     return this.mirror
     return this.mirror
   }
   }
 
 
+  /**
+   * Bind one namespace scope on the CALLER's plugin lifecycle — the service
+   * proxy binds `this.ctx` to the caller at call time, so the scope's disposer
+   * belongs to the calling fiber. The scope derives from the shared mirror
+   * (whose invalidation subscriptions live with the providing plugin), so
+   * binding adds no wire read of its own and activation never blocks on the
+   * settings transport.
+   * @param spec - domain-owned namespace contract.
+   * @returns the bound scope consumed by the domain's services and rows.
+   */
   bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T> {
   bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T> {
     const ctx = this.ctx
     const ctx = this.ctx
     const connection = ctx.get('connection') as ConnectionHandle
     const connection = ctx.get('connection') as ConnectionHandle