docs(settings): third-review contracts across READMEs, catalogs, and the write-path integrity note

The seam README states the JSON-shaped write boundary, watch-disposer
quiescence, async listener containment, and the drained teardown; the
provider README rewrites Behavior around the operation chain,
read-modify-write, writer lock, ready reconcile, and leaf-level YAML
diffs, and updates Known Limitations to the residual guarantees.
A new Agent Note records the round's decisions and supersedes the
original note's deferred-lockfile alternative (cross-linked in place).
Chinese counterparts updated pair-by-pair (three briefed minimal
updates, one whole-document translation); type-equiv, config, cordis,
and module-graph catalogs re-recorded.
This commit is contained in:
Yichen Jiang
2026-07-30 14:09:04 +08:00
parent 85a3a158dd
commit 3b1b912518
21 changed files with 152 additions and 45 deletions

View File

@@ -1345,7 +1345,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'settings/updated',
mode: 'emit',
signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void',
jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */',
jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * Listener failures are contained and logged — a sync throw and an async\n * rejection alike — except `INVARIANT`-coded failures, which rethrow\n * after every listener ran; that rethrow reaches the emitter only from\n * synchronous listeners, so invariant checks on this event must not be\n * async functions.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */',
summary: 'Committed change to one registered namespace\'s resolved value.',
},
{

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
README.md: af8df7c030757b330e034a1c46507fbe75c9bab8
README.zh.md: fc8943263b339baad1a92a1d0b0977b926e40f6e
README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257
README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` writes back atomically while preserving the user's YAML comments and any section owned by a plugin that is not currently loaded.
File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` re-reads the document under a writer lock before writing back atomically, preserving the user's YAML comments, any section owned by a plugin that is not currently loaded, and any on-disk change this process has not observed yet.
## Config
@@ -18,9 +18,13 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
## Behavior
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
- **Write-back is atomic, owner-only, and symlink-proof.** `persist` exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes.
- **Cross-namespace writes serialize on one document.** Every namespace shares the file, so persists from different namespace queues chain internally; each render sees the text the previous write committed.
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight reload, so nothing publishes after disposal.
- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit.
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure.
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal.
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
## Model Experience
@@ -33,6 +37,7 @@ No direct invalidation; the consuming plugin owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No cross-process write lock** — concurrent writers (for example TUI and web on one home) converge by atomic replace plus watcher reload, last write wins; a lockfile is deferred until real contention shows up.
- **Comment preservation is YAML-only** — JSON documents re-serialize without comments (JSON has none) and lose hand formatting.
- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check.
- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart.
- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described.
- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 原子写回,并保留用户的 YAML 注释以及当前未加载插件所拥有的分节。
文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。
## 配置
@@ -18,9 +18,13 @@
## 行为
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
- **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。
- **跨 namespace 写入在同一文档上串行。** 所有 namespace 共享一个文件,来自不同 namespace 队列的 persist 在内部串联;每次渲染都基于上一次写入提交后的文本。
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。
- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行,带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
## Model Experience
@@ -33,6 +37,7 @@
## Known Limitations and Deferred Work
- **无跨进程写锁** — 并发写入者(例如同一 home 上的 TUI 与 web)靠原子替换加 watcher 重载收敛,后写胜出;lockfile 等真实冲突出现再做。
- **注释保留仅限 YAML** — JSON 文档重新序列化,无注释(JSON 本身没有)且丢失手工排版。
- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。

View File

@@ -1,7 +1,9 @@
/**
* File-backed settings provider. One YAML or JSON document under the user's
* harness home carries every namespace section; external edits hot-publish
* through the seam and `update()` writes back preserving the user's comments.
* through the seam, and every write re-reads the document under a
* cross-process writer lock before patching it as a comment-preserving
* leaf-level diff.
* @module @deepseek-ai/dsh-settings-local
*/

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
README.md: ff6cdeb57a265dbaa9d5f50de1d558f1e3cb581f
README.zh.md: d820a5c1fa804455c439f1a155e7628f5118a49b
README.md: ec9f0e09c47015edd8495dac48beb610e0b5cdc5
README.zh.md: 6d0a760f9b1bbef21881a03933d0fe5b9fc3cd0d

View File

@@ -9,10 +9,10 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces.
- `get(ns)` — resolved value, `undefined` while unregistered.
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults).
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest.
- Service teardown refuses new writes and drains every queued write before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners.
- Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
## Provider contract
@@ -33,5 +33,5 @@ No direct invalidation; a consumer that folds a settings value into the request
## Known Limitations and Deferred Work
- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet.
- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider is last-write-wins).
- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins).
- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure.

View File

@@ -9,10 +9,10 @@
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。
- `get(ns)` — 解析值;未注册时为 `undefined`。
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener。
- 服务卸载先拒绝新写入并排干全部排队写入后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。
- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
## Provider 契约
@@ -33,5 +33,5 @@
## Known Limitations and Deferred Work
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 为后写胜出)。
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。
- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。