fix(client): harden settings describe mirror

This commit is contained in:
Yichen Jiang
2026-08-18 15:26:16 +08:00
parent 02008db244
commit cf7d485b5e
28 changed files with 285 additions and 80 deletions
@@ -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-08-12-plugin-owned-settings-surface.md
2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3
2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7
2026-08-12-plugin-owned-settings-surface.md: 722e6cfbe890418e8305f89790e76976027d7775
2026-08-12-plugin-owned-settings-surface.zh.md: 93e5227d5f6a629fd32f5a2fe22e9882c7f7c5ac
@@ -22,7 +22,7 @@ Together the two meant a user-authored plugin was configurable only by hand-edit
**`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list.
**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer.
**The tab drives dispatch from the served namespaces.** It derives the current served set from `ctx.settingsScope.describe()` and follows that shared settings mirror, while its own listener follows the card slot ledger. It dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the mirror answer. The later [settings describe mirror decision](2026-08-17-settings-describe-mirror.md) owns the browser-wide read and invalidation lifecycle.
Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line.
@@ -56,6 +56,6 @@ A plugin distributed outside this repository is configurable from the settings p
Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path.
The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect.
The section and its cards add no `settings.describe` reads: both derive from the browser-wide mirror. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the mirror's current answer joins on the next commit or reconnect.
Two frictions remain for an author outside this repository, both recorded in the section's README. The browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built.
@@ -22,7 +22,7 @@ Status: implemented
**`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。
**标签页以被服务的命名空间驱动派发。**读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries``ctx.slots.subscribe`)与协议答复算出
**标签页以被服务的命名空间驱动派发。**`ctx.settingsScope.describe()` 派生当前被服务的集合并跟随该共享 settings 镜像,自身的监听器只跟随卡片 slot 账本;随后为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries``ctx.slots.subscribe`)与镜像应答算出。后续的 [settings describe 镜像决策](2026-08-17-settings-describe-mirror.md)持有浏览器全局的读取与失效生命周期
以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme``permission``llm-*``agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。
@@ -56,6 +56,6 @@ Status: implemented
以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。
分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。
分区与其中的卡片都不再新增 `settings.describe` 读取:两者都从浏览器全局的镜像派生。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在镜像当前应答之后才被注册的命名空间,要等下一次提交或重连才会加入。
对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。
@@ -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-08-17-settings-describe-mirror.md
2026-08-17-settings-describe-mirror.md: c845c8ab65c526f27d09d1efdfd584a757ef00ba
2026-08-17-settings-describe-mirror.zh.md: 1229aaf9900178b6a86d7f3cdedeebc2ac99663c
2026-08-17-settings-describe-mirror.md: a3774699ff328a44aed192a16dea0fa19d03c83c
2026-08-17-settings-describe-mirror.zh.md: c57f5630b4bee0cd77a29a6f5458cb439c7f0585
@@ -10,9 +10,11 @@ A cold web boot issued `settings.describe` fifteen times inside ~200ms, and the
## 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.
**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 owns a run before its loading publication can synchronously reenter `load()`, then clears inside the run's own try/finally in the same synchronous segment that observes the rerun flag; a `.finally()` on the returned promise would run one microtask later and let a refresh landing in that gap mark a rerun nobody reads.
`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`).
`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; the fold invalidates any older in-flight answer, and a write before the first held document reruns that read instead of publishing a partial document. 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()`, the shared read/fold face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`).
This decision updates the browser read and invalidation mechanics recorded by [Host-backed Web preferences](../bug-fix/2026-08-06-host-backed-web-preferences.md) and [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), while preserving their preference-ownership and namespace-exposure decisions. It also replaces the direct settings-read description in [official DeepSeek first-run credential setup](../feature/2026-07-30-deepseek-onboarding-credential-setup.md); that join now derives its settings half from this mirror.
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.
@@ -10,9 +10,11 @@ Status: implemented
## 决定
**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated``connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读——在飞槽位在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空,因为挂在返回 promise `.finally()` 要晚一个微任务执行,落入该间隙的 `load()` 会标记一个无人读取的 rerun。
**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated``connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读在飞槽位会在 loading 发布同步重入 `load()` 之前先取得 run 的所有权,随后在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空;若把清理挂在返回 promise 的 `.finally()` 上,它要晚一个微任务执行,落入该间隙的刷新会标记一个无人读取的 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`)。
`bind()` 返回的 `SettingsScope<T>` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;这次折叠会废弃更早发出的在飞应答,而首次完整文档尚未建立时到达的写入会让该读取重跑,不会把单个 namespace 发布成残缺文档。失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 schema 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 提供的共享读/折叠面(`getSnapshot``subscribe``ensure``acceptView`)。
本决策更新了[通过 Host settings 持久化 Web 用户偏好](../bug-fix/2026-08-06-host-backed-web-preferences.md)和[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)所记录的浏览器读取与失效机制,同时保留其中关于偏好所有权与命名空间暴露的决策。它也取代了 [DeepSeek 官方首次使用凭据配置](../feature/2026-07-30-deepseek-onboarding-credential-setup.md)中的设置直读描述;该联接的 settings 部分现在从本镜像派生。
冷启动预算由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉在两次读取:镜像在绑定时的急切读取,加上首连 reset 触发的读取——后者是有意保留的:它关闭了「文档提交落在急切 HTTP 读取与 SSE 订阅之间、其失效通知丢失」的窗口。方案最初的一次读取目标,若不接受该失效丢失窗口、或不把首次读取推迟到 SSE 流建立之后,无法达成。
@@ -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/bug-fix/2026-08-06-host-backed-web-preferences.md
2026-08-06-host-backed-web-preferences.md: 5d90f2be7c8b4030e9bdc00eed2769491ec009e5
2026-08-06-host-backed-web-preferences.zh.md: c861c45bff299e06841165a2b36d0781e8f54d99
2026-08-06-host-backed-web-preferences.md: 2e33d05417bf6c347a57b5c0b6c7281ff1392b5b
2026-08-06-host-backed-web-preferences.zh.md: 1d3518bb33d334d89408916a5f5210a45b938a01
@@ -12,9 +12,9 @@ The first theme implementation moved only Appearance to Host settings but awaite
## Decision
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary.
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace to a loopback client; field roles still redact secrets.
`dsh-client-ui-settings` provides `ctx.settingsScope.bind(spec)`, which owns one lifecycle per namespace as the browser mirror of the Host-side settings owner seam. It installs `settings/document-updated` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local.
`dsh-client-ui-settings` owns one browser-wide settings describe mirror and provides `ctx.settingsScope.bind(spec)` as a per-namespace selector over it. The mirror installs `settings/document-updated` and `connection/reset` listeners before starting its background read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Each bound scope publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to, without adding a wire read or listener of its own. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. The shared read and invalidation lifecycle is specified by the later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md).
User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence.
@@ -12,9 +12,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo
## 决策
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference``zh``en`,缺失时交由浏览器决定)、`ui-theme.preference``light``dark``system`,默认为 `system`),以及 `ui-conversation.busyEnter``queue``steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference``zh``en`,缺失时交由浏览器决定)、`ui-theme.preference``light``dark``system`,默认为 `system`),以及 `ui-conversation.busyEnter``queue``steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会向回环客户端服务每一个已注册的 namespace;字段角色仍会脱敏机密值
`dsh-client-ui-settings` 提供 `ctx.settingsScope.bind(spec)`,为每个 namespace 持有一份生命周期,作为 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/document-updated``connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。
`dsh-client-ui-settings` 持有一个浏览器全局的 settings describe 镜像,并提供 `ctx.settingsScope.bind(spec)` 作为该镜像上的逐 namespace selector。镜像在开始后台读取之前安装 `settings/document-updated``connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。每个绑定的 scope 会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式),自身不再增加协议读取或监听器。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。共享读取与失效生命周期由后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)规定。
用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。
@@ -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/feature/2026-07-30-deepseek-onboarding-credential-setup.md
2026-07-30-deepseek-onboarding-credential-setup.md: 823d10a723af70ec4ff51018b8b86198db0f5c29
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 7e8d79c23c4b1489bfd818c90558f36509635486
2026-07-30-deepseek-onboarding-credential-setup.md: 87533e7a55f9b1f05f6a4ba58c3c9888780c158c
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 575d8232b4837b57d9508ed613530606c05db1f7
@@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
## Decision
**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only.
**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering.
**The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md).
@@ -10,7 +10,7 @@ Status: implemented
## 决策
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)持有这次 settings 读取及其失效顺序。
**设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。
+2 -2
View File
@@ -35,7 +35,7 @@ afterAll(async () => {
})
describe('startup RPC budget', () => {
it('keeps cold-boot settings.describe within the mirror budget', async () => {
it('keeps cold-boot settings.describe at the mirror count', async () => {
page = await newEnglishPage(browser)
watchConsole(page)
const calls: string[] = []
@@ -49,6 +49,6 @@ describe('startup RPC budget', () => {
await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 })
await page.waitForTimeout(3000)
const describeCount = calls.filter(method => method === 'settings.describe').length
expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBeLessThanOrEqual(DESCRIBE_BUDGET)
expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET)
})
})
@@ -194,7 +194,7 @@ export class AgentPresetSettingsController {
/**
* @param api - the agent-preset and settings wire faces (roster and default write).
* @param describeFace - the shared mirror's read-only face (writability source).
* @param describeFace - the shared mirror's describe face (writability source).
*/
constructor(
private readonly api: IApiClient,
@@ -73,7 +73,7 @@ describe('PermissionRow', () => {
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
},
})
mount(controller)
const button = await screen.findByRole('button', { name: 'Read Only' })
@@ -100,7 +100,7 @@ describe('PermissionRow', () => {
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
},
})
mount(controller)
fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
@@ -124,7 +124,7 @@ describe('PermissionRow', () => {
settings: {
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
mutate: vi.fn(),
} as never,
},
})
const rendered = mount(absent)
await waitFor(() => { expect(rendered.container.textContent).toBe('') })
@@ -134,7 +134,7 @@ describe('PermissionRow', () => {
settings: {
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
mutate: vi.fn(),
} as never,
},
})
mount(readonly)
expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
@@ -155,7 +155,7 @@ describe('PermissionRow', () => {
error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} },
},
}),
} as never,
},
})
mount(controller)
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
@@ -182,15 +182,26 @@ describe('permission settings store', () => {
expect(mutate).not.toHaveBeenCalled()
const thrown = permissionController({
// Promise consumers must contain unknown rejection values from a
// transport implementation, including non-Error legacy clients.
describe: () => Promise.reject('disconnected' as never),
describe: async () => { throw 'disconnected' },
mutate,
}).controller
await thrown.load()
expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' })
})
it('hides the row in a remote browser instead of loading forever', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
const wire = { settings: { describe: describeCall, mutate } } as never
const mirror = new SettingsDescribeMirror(wire, 'memory')
const controller = new PermissionPresetSettingsController(mirror, wire, schema)
await controller.load()
expect(controller.store.getSnapshot().status).toBe('unavailable')
await controller.select('workspace-write')
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('follows a mirror refresh without an own read once loaded', async () => {
const describe = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('read-only', 1)] }))
@@ -29,7 +29,7 @@ export class SettingsDocumentStore {
/**
* @param api - loopback settings wire face that opens the provider document.
* @param describeFace - the shared mirror's read-only face (`hasDocument` source).
* @param describeFace - the shared mirror's describe face (`hasDocument` source).
*/
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
@@ -82,7 +82,7 @@ describe('SettingsDocumentAction', () => {
})),
openDocument,
},
} as never)
})
render(<SettingsDocumentAction
{...kit}
t={t}
@@ -146,7 +146,7 @@ describe('SettingsDocumentAction', () => {
result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } },
})),
},
} as never)
})
render(<SettingsDocumentAction
{...kit}
t={t}
@@ -41,7 +41,7 @@ describe('SettingsDocumentStore', () => {
it('loads provider metadata and asks the settings domain to open its document', async () => {
const describe = vi.fn(() => Promise.resolve(response(true)))
const openDocument = vi.fn(() => Promise.resolve(opened()))
const controller = derivedDocumentStore({ settings: { describe, openDocument } } as never)
const controller = derivedDocumentStore({ settings: { describe, openDocument } })
await controller.load()
expect(controller.store.getSnapshot()).toEqual({
status: 'ready', opening: false, error: null,
@@ -54,7 +54,7 @@ describe('SettingsDocumentStore', () => {
const openDocument = vi.fn(() => Promise.resolve(opened()))
const absent = derivedDocumentStore({
settings: { describe: () => Promise.resolve(response()), openDocument },
} as never)
})
await absent.load()
await absent.open()
expect(absent.store.getSnapshot().status).toBe('unavailable')
@@ -62,13 +62,13 @@ describe('SettingsDocumentStore', () => {
const failed = derivedDocumentStore({
settings: { describe: () => Promise.reject(new Error('offline')), openDocument },
} as never)
})
await failed.load()
expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
const rejected = derivedDocumentStore({
settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument },
} as never)
})
await rejected.load()
expect(rejected.store.getSnapshot()).toMatchObject({
status: 'unavailable', error: 'provider failed',
@@ -80,7 +80,7 @@ describe('SettingsDocumentStore', () => {
const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve }))
const controller = derivedDocumentStore({
settings: { describe: () => Promise.resolve(response(true)), openDocument },
} as never)
})
await controller.load()
const first = controller.open()
const second = controller.open()
@@ -102,7 +102,7 @@ describe('SettingsDocumentStore', () => {
describe: vi.fn(() => Promise.resolve(response(true))),
openDocument: () => new Promise((_, reject) => { rejectOpen = reject }),
},
} as never)
})
await controller.load()
expect(controller.store.getSnapshot().status).toBe('ready')
const opening = controller.open()
@@ -99,10 +99,11 @@ export function apply(ctx: ClientContext): void {
t,
})
// Pushed invalidations converge every open surface without polling: any
// settings/credentials/topology change refetches once the page loaded. The
// welcome notice follows its settings scope, so the shared mirror already
// keeps it fresh without a subscription here.
// Pushed invalidations converge every open surface without polling. The
// settingsScope injection makes ui-settings activate first, and remote
// dispatch preserves listener order; its listener therefore starts the
// mirror refresh before this store joins that refresh. The welcome notice
// follows its settings scope, so it needs no subscription here.
ctx.effect(() => {
const refreshModels = (): void => { refreshIfLoaded(controller) }
const disposers = [
@@ -1,6 +1,6 @@
/**
* Models settings page store: one snapshot joining the configurable-provider
* directory (`llm.providers`), the settings namespaces (`settings.describe`),
* directory (`llm.providers`), the settings namespaces (shared settings mirror),
* and the referenced credentials (`credentials.describe`). The host stays the
* single fact source — every mutation writes through the wire and the page
* re-renders from the next describe, pushed or refetched.
@@ -116,7 +116,7 @@ export class ModelsSettingsStore {
/**
* @param api - the wire face (credentials/llm domains, and settings writes).
* @param describeFace - the shared mirror's read-only face (namespace views and writability).
* @param describeFace - the shared mirror's describe face (namespace views and writability).
*/
constructor(
private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>,
@@ -127,8 +127,9 @@ export class ModelsSettingsStore {
/**
* Refresh the whole page snapshot: the provider directory and the mirror's
* settings answer in parallel, then one batched credential describe over
* every referenced ref. A failure keeps the last good rows and surfaces the
* error.
* every referenced ref. Provider failure or absence of an initial settings
* answer keeps the last good rows and surfaces an error; a failed settings
* refresh reuses the mirror's held view.
* @returns nothing; the snapshot carries the outcome.
*/
async load(): Promise<void> {
@@ -145,7 +146,7 @@ export class ModelsSettingsStore {
if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
const mirrored = this.describeFace.getSnapshot()
if (mirrored.view === undefined) {
throw new Error(mirrored.error ?? 'settings have not answered yet')
throw new Error(mirrored.error ?? 'settings are unavailable in this browser')
}
providers = providersResponse.result.value.providers
writable = mirrored.view.writable
@@ -34,11 +34,16 @@ export function decodeWelcomeSection(section: unknown): WelcomeSection {
: {}
}
/* v8 ignore next 3 -- closed-union default only defends future source widening */
function assertNever(_value: never): never {
throw new Error('unexpected welcome settings status')
}
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
export class WelcomeNoticeStore {
/** uSES-safe state source shared by the registered welcome step. */
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
status: 'idle' as const, acknowledged: false, error: null,
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore<WelcomeNoticeState>({
status: 'idle', acknowledged: false, error: null,
})
private localAcknowledged = false
@@ -51,10 +56,14 @@ export class WelcomeNoticeStore {
*/
constructor(private readonly scope: SettingsScope<WelcomeSection>) {}
/** Begin following the bound scope (idempotent) and publish its current answer. */
async load(): Promise<void> {
/**
* Begin following the bound scope (idempotent) and publish its current answer.
* @returns settlement after the current answer is published.
*/
load(): Promise<void> {
this.following ??= this.scope.subscribe(() => { this.derive() })
this.derive()
return Promise.resolve()
}
/**
@@ -122,7 +131,10 @@ export class WelcomeNoticeStore {
state.acknowledged = acknowledged
state.error = null
})
return
}
/* v8 ignore next -- every current settings scope status is handled above */
default: return assertNever(scope.status)
}
}
}
@@ -18,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
async function bench(isLoopback = true, settings?: object) {
async function bench(isLoopback = true, settings?: object, services: object = {}) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
@@ -29,7 +29,10 @@ async function bench(isLoopback = true, settings?: object) {
// Without a settings face the mirror's reads fail and stay contained; the
// Models join itself never fetches until a section actually loads. The real
// ui-settings apply also provides the settingsSchema service.
ctx.provide('connection', { api: settings === undefined ? {} : { settings }, isLoopback } as never)
ctx.provide('connection', {
api: settings === undefined ? services : { ...services, settings },
isLoopback,
} as never)
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
}
@@ -253,4 +256,49 @@ describe('pushed invalidations', () => {
expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
})
})
it('joins the refreshed mirror view on a settings invalidation', async () => {
let revision = 1
const describe = vi.fn(() => Promise.resolve({
rpcId: `apply-models-${revision}` as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: false,
namespaces: [{
ns: 'llm-test',
schema: {},
value: {},
applies: 'live' as const,
secrets: [],
revision,
}],
},
},
}))
const providers = vi.fn(() => Promise.resolve({
rpcId: 'apply-models-providers' as never,
result: { ok: true as const, value: { providers: [] } },
}))
const b = await bench(true, { describe }, { llm: { providers } })
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.section')
.find(candidate => candidate.options.id === 'models')!
const injected = (
entry.inject as unknown as
() => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
)()
await injected.controller.load()
expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1)
revision = 2
b.ctx.remote.$dispatch('settings/document-updated', ['llm-test', revision])
await vi.waitFor(() => {
expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2)
})
expect(describe).toHaveBeenCalledTimes(2)
})
})
@@ -125,8 +125,7 @@ describe('ModelsSettingsStore', () => {
it('stringifies a non-Error credential transport rejection', async () => {
const { face, mirror } = api({
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
describeCredentials: () => Promise.reject('credential transport refusal'),
describeCredentials: async () => { throw 'credential transport refusal' },
})
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
await expect(store.load()).resolves.toBeUndefined()
@@ -223,10 +222,42 @@ describe('edge joins', () => {
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
})
it('reports a terminally unavailable settings mirror precisely', async () => {
const { face } = api()
const store = new ModelsSettingsStore(
face,
settingsSchema,
new SettingsDescribeMirror(face, 'memory'),
)
await store.load()
expect(store.store.getSnapshot()).toMatchObject({
status: 'error',
error: 'settings are unavailable in this browser',
})
})
it('reuses a held settings view after its refresh fails', async () => {
let settingsCall = 0
const { face, mirror } = api({
describeSettings: () => {
settingsCall += 1
return Promise.resolve(settingsCall === 1
? ok({ writable: true, hasDocument: false, namespaces: NAMESPACES })
: fail('settings refresh down'))
},
})
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
await store.load()
await mirror.load()
expect(mirror.getSnapshot().error).toBe('settings refresh down')
await store.load()
expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
expect(store.store.getSnapshot().rows).toHaveLength(4)
})
it('stringifies a non-Error load failure', async () => {
// The wire can surface non-Error throwables; the store must stringify them.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
const { face, mirror } = api({ providers: () => Promise.reject('plain refusal') })
const { face, mirror } = api({ providers: async () => { throw 'plain refusal' } })
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
await store.load()
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
@@ -49,7 +49,7 @@ export class ConfigurablePluginsTabController {
private readonly unsubscribe: () => void
/**
* @param describeFace - the shared mirror's read-only face; its refreshes
* @param describeFace - the shared mirror's describe face; its refreshes
* (document commits, reconnects) are what keep the served set current.
* @param entries - reads the cards currently registered into the section's slot.
*/
@@ -2,7 +2,7 @@
* Client mirror of the Host settings document: the one `settings.describe`
* reader in the browser. Every settings consumer derives from this store —
* per-namespace scopes through `SettingsScopeBinder.bind`, cross-namespace
* surfaces through the binder's read-only describe face — so startup cost and
* surfaces through the binder's shared describe face — so startup cost and
* freshness are properties of this class, not of how many features own a
* preference. The Host stays the fact source: the mirror re-reads on the
* invalidations its owning plugin subscribes to and folds write answers in
@@ -60,7 +60,7 @@ export interface SettingsDescribeFace {
ensure(): Promise<void>
/**
* Fold one write answer's namespace view into the held view without a wire
* read.
* read, invalidating any older read still in flight.
* @param view - the namespace view a settings write answered with.
*/
acceptView(view: SettingsNamespaceView): void
@@ -117,7 +117,8 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
this.rerun = true
return this.inFlight
}
const run = this.run()
// Own the slot before the loading publication can synchronously reenter load().
const run = Promise.resolve().then(() => this.run())
this.inFlight = run
return run
}
@@ -137,12 +138,15 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
/**
* Fold one write answer's namespace view into the held view without a wire
* read. A no-op until a first answer exists — a write cannot precede the
* read that supplied its `expectedRevision`.
* read, and invalidate any read still in flight. With no held document, the
* answer is not published as a partial document; an in-flight read reruns so
* it cannot publish a document fetched before the write committed.
* @param view - the namespace view a settings write answered with.
*/
acceptView(view: SettingsNamespaceView): void {
const before = this.store.getSnapshot()
this.generation += 1
if (this.inFlight !== undefined) this.rerun = true
if (before.view === undefined) return
const namespaces = before.view.namespaces.some(row => row.ns === view.ns)
? before.view.namespaces.map(row => row.ns === view.ns ? view : row)
@@ -166,10 +170,14 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
// that gap would mark a rerun nobody reads, losing the read.
try {
do {
this.rerun = false
const generation = ++this.generation
const before = this.store.getSnapshot()
if (before.status === 'idle') this.store.set({ ...before, status: 'loading' })
// Cleared immediately before the wire read goes out: a load() marked
// earlier (including one reentering from the loading publish above)
// is covered by this very read, while one landing after needs the
// rerun.
this.rerun = false
const generation = ++this.generation
let outcome: { view: SettingsDescribeView } | { failure: string }
try {
const response = await this.api.settings.describe({})
@@ -179,6 +187,7 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
} catch (error) {
outcome = { failure: error instanceof Error ? error.message : String(error) }
}
// A write answer invalidates a document read before that write committed.
if (generation !== this.generation) continue
if ('view' in outcome) {
this.store.set({ status: 'ready', view: outcome.view, error: null })
@@ -192,9 +201,13 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
error: outcome.failure,
})
}
} while (this.rerun)
} while (this.shouldRerun())
} finally {
this.inFlight = undefined
}
}
private shouldRerun(): boolean {
return this.rerun
}
}
@@ -33,17 +33,22 @@ function deferred<T>() {
}
describe('SettingsDescribeMirror', () => {
it('folds concurrent load calls into the in-flight read plus one rerun', async () => {
it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => {
const gate = deferred<RpcResponse<SettingsDescribeView>>()
const describeCall = vi.fn()
.mockReturnValueOnce(gate.promise)
.mockResolvedValue(described([view('theme', 1)]))
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
const first = mirror.load()
const second = mirror.load()
const third = mirror.load()
// Issued before the wire read goes out: covered by that read, no rerun.
const early = mirror.load()
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(1)
// Issued while the read is on the wire: exactly one rerun, however many.
const mid = mirror.load()
const midToo = mirror.load()
gate.resolve(described([view('theme', 0)]))
await Promise.all([first, second, third])
await Promise.all([first, early, mid, midToo])
expect(describeCall).toHaveBeenCalledTimes(2)
expect(mirror.getSnapshot().status).toBe('ready')
expect(mirror.namespace('theme')?.revision).toBe(1)
@@ -139,16 +144,73 @@ describe('SettingsDescribeMirror', () => {
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
})
it('suppresses a stale answer that lost to a newer generation', async () => {
it('starts no second run for a load issued inside the loading publish', async () => {
const gate = deferred<RpcResponse<SettingsDescribeView>>()
const describeCall = vi.fn().mockReturnValue(gate.promise)
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
let reentered = false
const unsubscribe = mirror.subscribe(() => {
if (reentered) return
reentered = true
void mirror.load()
})
const loading = mirror.load()
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(1)
gate.resolve(described([view('theme', 1)]))
await loading
unsubscribe()
// The reentrant load folded into the first run rather than racing it.
expect(describeCall).toHaveBeenCalledTimes(1)
expect(mirror.getSnapshot().status).toBe('ready')
})
it('lets the first read cover a write folded inside the loading publish', async () => {
const describeCall = vi.fn().mockResolvedValue(described([view('theme', 2)]))
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
const unsubscribe = mirror.subscribe(() => {
unsubscribe()
mirror.acceptView(view('theme', 2))
})
await mirror.load()
expect(describeCall).toHaveBeenCalledTimes(1)
expect(mirror.getSnapshot().status).toBe('ready')
expect(mirror.namespace('theme')?.revision).toBe(2)
})
it('re-reads after a folded write invalidates an in-flight document', async () => {
const slow = deferred<RpcResponse<SettingsDescribeView>>()
const describeCall = vi.fn()
.mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)]))
.mockReturnValueOnce(slow.promise)
.mockResolvedValueOnce(described([view('theme', 5), view('locale', 2)]))
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
await mirror.load()
expect(describeCall).toHaveBeenCalledTimes(1)
const stale = mirror.load()
await Promise.resolve()
mirror.acceptView(view('theme', 5))
slow.resolve(described([view('theme', 4), view('locale', 2)]))
await stale
expect(describeCall).toHaveBeenCalledTimes(3)
expect(mirror.namespace('theme')?.revision).toBe(5)
expect(mirror.namespace('locale')?.revision).toBe(2)
})
it('re-reads after a pre-answer write invalidates the in-flight document', async () => {
const slow = deferred<RpcResponse<SettingsDescribeView>>()
const describeCall = vi.fn()
.mockReturnValueOnce(slow.promise)
.mockResolvedValue(described([view('theme', 8)]))
.mockResolvedValueOnce(described([view('theme', 2)]))
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
const first = mirror.load()
const second = mirror.load()
const loading = mirror.load()
await Promise.resolve()
mirror.acceptView(view('theme', 2))
slow.resolve(described([view('theme', 1)]))
await Promise.all([first, second])
expect(mirror.namespace('theme')?.revision).toBe(8)
await loading
expect(describeCall).toHaveBeenCalledTimes(2)
expect(mirror.namespace('theme')?.revision).toBe(2)
})
})
@@ -188,6 +188,28 @@ describe('SettingsScopeController', () => {
expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 })
})
it('re-reads after a revisionless first write lands during the initial read', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describeCall = vi.fn()
.mockReturnValueOnce(initial.promise)
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
const loading = mirror.load()
await Promise.resolve()
await scope.set('preference', 'dark')
initial.resolve(described({ preference: 'system' }, 1))
await loading
expect(mutate).toHaveBeenCalledWith({
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})
expect(describeCall).toHaveBeenCalledTimes(2)
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 })
})
it('recovers the latest rejected or thrown write from Host state', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'system' }, 2))