deepseek-harness/docs/subsystems/settings.zh.md

311 lines
16 KiB
Markdown
Raw Normal View History

fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
# 用户设置
[English](settings.md) | 中文
[dsh-settings](../../packages/settings/settings) 的用户设置 seam 持有一份按 namespace 分节的用户文档,并把每个已注册 namespace 解析为:schema 默认值,然后注册方的组合 `base`,最后用户分节。[dsh-settings-local](../../packages/settings/settings-local) 这类提供方存储原始文档并推送外部编辑;消费方插件注册 schema 后读取或观察解析值。组合配置仍留在 `cordis.yml`——namespace 只承载用户可编辑子集。
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
来源:[`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts)
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
## 标识
2026-08-09 15:27:21 +08:00
namespace 命名用户文档中一个归插件所有的分节。brand 防止调用方将设置 namespace 与在包或进程之间传递的其他 id 混用;构造时校验小写 kebab-case 语法。
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
```ts type-equiv
/** Nominal id of one registered settings namespace. */
type SettingsNamespace = Branded<'SettingsNamespace'>
```
## 注册
注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose(资源释放)该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
```ts type-equiv
/** Registration options beyond the namespace schema. */
interface SettingsRegisterOptions<T> {
/** Composition-layer values resolved below the user layer (entry-config subset). */
base?: Partial<T>
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
applies?: SettingsApplies
fix(llm): size unknown models and refuse a section that cannot be served Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
2026-08-04 13:32:56 +08:00
/**
* Reject a resolved section the owner could not act on, for constraints its
* schema cannot express — a cross-field requirement, or one field's validity
* depending on another's. Throwing here refuses the *write* that produced the
* value, so a caller learns at `update`/`replace`/`mutate` instead of storing
* something that would silently disable the owner.
*
* Kept separate from the schema because the schema is also what a
* configuration surface renders and what an absent section resolves through;
* folding a cross-field check into it would change both.
*
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
* Once the owner is registered, a stored section that fails this keeps the
* namespace's last good value and warns, exactly as a schema failure does,
* so an externally edited document cannot strand a running owner. At
* registration there is no last good value yet, so a stored section that
* already fails rejects the registration itself — again exactly as a schema
* failure does.
fix(llm): size unknown models and refuse a section that cannot be served Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
2026-08-04 13:32:56 +08:00
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
}
```
`validate` 在 schema 接纳该值之后运行,因此它看到的默认值和组合 base 与 owner 实际看到的完全一致。`dsh-llm-pi-ai` 用它在写入处拒绝自己无法服务的提供方 profile,而不是先存下来、再让该 namespace 下每条路由失效。
fix(llm): size unknown models and refuse a section that cannot be served Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
2026-08-04 13:32:56 +08:00
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
`applies` 是 UI 提示而非机制:`restart` 的 owner 只是从不 watch,其值在构造期读取一次,配置界面可为待生效变更加标。
```ts type-equiv
/** When a namespace's changes take effect for its owner. */
type SettingsApplies = 'live' | 'restart'
```
## Owner scope
scope 是面向 owner 的句柄。`update` 把稀疏 patch 只合并进用户分节(绝不进 `base`);`replace` 整体替换分节,是删除/重置路径——替换中缺席的键重新继承 `base` 与 schema 默认值。同一 namespace 的写入按调用顺序串行,解析值是深冻结快照。
```ts type-equiv
/** Owner-facing handle for one registered namespace. */
interface SettingsScope<T> {
/** Current resolved value: schema defaults, then `base`, then the user layer. */
get(): T
/**
fix(settings): close cross-namespace, dispatch, and lifecycle races from second review Confirmed and fixed, each with a regression test that failed first: - Concurrent writes to different namespaces lost whole sections on disk (each persist rendered the full document from a stale text): the local provider serializes render->write->rename->text-commit on one internal persist chain shared by every namespace queue. - One throwing settings/updated listener starved the rest (cordis emit stops at the first throw): commit fans out per listener via events.dispatch, contains individual failures, and rethrows the first INVARIANT-coded error only after every listener ran. - Write queues ignored fiber/service lifecycle: the base init now registers a teardown that refuses new writes and drains queued chains; queued tasks re-verify service liveness and namespace ownership before running and again before committing, so a registrant disposed mid-flight is never notified and a disposed service never commits. - Async watcher invocations could interleave (a slow stale call applied last): each watcher carries a serialized invocation chain — one call at a time, in commit order; JSDoc/doc pages state the async timing. - update/replace borrowed the caller's object until the queued task ran: inputs are structured-clone snapshotted at call time; non-cloneable plain objects reject with a typed error. - Composition guard now proves the documented fallback: the consumer uses the optional scoped-inject shape and boots both with the settings entry (hot publish) and without it (entry-config resolution, no scope). - core-data-structures index: settings.md row added to the sub-page table in core.md/core.zh.md. Both packages hold per-file 100% coverage across repeated runs.
2026-07-29 10:07:28 +08:00
* Observe committed changes to this namespace's resolved value. Invocations
* of one callback run asynchronously, one at a time, in commit order; a
* rejection is contained and logged like a sync throw. After the disposer
* returns, no further invocation starts — one already queued is skipped;
* one already started still settles, and service disposal waits for it.
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
* @param callback - invoked after each commit with the next and previous values.
* @returns the disposer removing this observer.
*/
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
/**
* Merge a partial patch into this namespace's user layer and persist it.
2026-08-09 15:27:21 +08:00
* @param patch - plain-object patch over the user section; JSON-compatible data
* only (non-JSON values reject with their path before anything persists).
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
*/
update(patch: object): Promise<void>
/**
* Replace this namespace's user section wholesale; absent keys re-inherit
* the composition `base` and schema defaults (`replace({})` resets all).
2026-08-09 15:27:21 +08:00
* @param section - the complete next user section; JSON-compatible data only,
* as for {@link update}.
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
*/
replace(section: object): Promise<void>
}
```
## 描述符
`describe()` 为配置界面序列化每个已注册 namespace:schemastery 的 `toJSON()` 封装结构驱动 schema 渲染的表单,解析值填充表单,分离出的 `base`/`user` 层让表单按字段是否出现在 user 层标注「用户已覆盖」。`describe({ redactSecrets: true })`——每个对外传输接口都必须传入——从三层剥离 `role('secret')` 字段并枚举其 `{path, set}` slot,页面因此能渲染只写输入框而永远收不到机密值。
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
```ts type-equiv
/** One registered namespace as surfaced to configuration UIs. */
interface SettingsDescriptor {
/** The registered namespace. */
ns: SettingsNamespace
/** Serialized schemastery schema (`schema.toJSON()`). */
schema: unknown
/** Current resolved value. */
value: unknown
feat(settings): detect stale writers with a revision, and announce raw changes The remaining P1 from the #939 review, plus the P2 it shares a mechanism with. Nothing carried a version, so two tabs editing one namespace silently overwrote each other — reproduced as tab B's `reasoning` lost to tab A's older draft. The seam's per-namespace write queue orders writes; it cannot tell a fresh writer from one replaying a snapshot a predecessor superseded. Each namespace now carries a monotonic `revision` over its RAW section. A write may send `expectedRevision`, checked at the FRONT of the queue (not at call time, which would race the very predecessor it guards against); a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, carrying both revisions. The editor captures the revision it opened at and, on conflict, asks the user to reopen rather than replaying its snapshot. The same counter fixes the missing broadcast. `settings/updated` is gated on the resolved value — correct for consumers, wrong for configuration surfaces: storing an override equal to the composition base leaves the resolved value alone while changing what the document says (the field is now overridden, not inherited) and moving every open editor's revision. `settings/document-updated (ns, revision)` fires on any raw-section change, in-process or external, and `host/settings-changed` now rides it. That event also closes the stale model picker: editing a provider's `models` changes no route, so `llm/adapters-updated` never fired and an open picker kept serving the old catalog. A change to an exposed provider namespace now emits `host/models-changed` too — that namespace holds the catalog. Docs: both sides of the five touched README pairs, a type-equiv block for `SettingsPathOp`, and an Agent Note recording what the plane exposes and who may overwrite what. The deferred wire-redaction gaps (secrets behind union/intersection/transform, `.default(...)` in the served envelope, schema text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are recorded as TODO(settings-wire-redaction) and in Known Limitations rather than half-fixed.
2026-07-30 19:24:21 +08:00
/**
* Monotonic revision of the raw user section this descriptor was read at.
* Send it back as `expectedRevision` on a write to refuse a stale one.
*/
revision: number
/** Registrant's composition `base` layer (detached), when one was declared. */
base?: unknown
/**
* Raw user section from the stored document (detached), when one exists and
* is well-formed; a field's presence here is what marks it user-overridden.
*/
user?: unknown
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
/** Owner's declared effect timing. */
applies: SettingsApplies
/** Schema-declared secret positions; present only under `redactSecrets`. */
secrets?: RedactedSecret[]
}
```
feat(settings): detect stale writers with a revision, and announce raw changes The remaining P1 from the #939 review, plus the P2 it shares a mechanism with. Nothing carried a version, so two tabs editing one namespace silently overwrote each other — reproduced as tab B's `reasoning` lost to tab A's older draft. The seam's per-namespace write queue orders writes; it cannot tell a fresh writer from one replaying a snapshot a predecessor superseded. Each namespace now carries a monotonic `revision` over its RAW section. A write may send `expectedRevision`, checked at the FRONT of the queue (not at call time, which would race the very predecessor it guards against); a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, carrying both revisions. The editor captures the revision it opened at and, on conflict, asks the user to reopen rather than replaying its snapshot. The same counter fixes the missing broadcast. `settings/updated` is gated on the resolved value — correct for consumers, wrong for configuration surfaces: storing an override equal to the composition base leaves the resolved value alone while changing what the document says (the field is now overridden, not inherited) and moving every open editor's revision. `settings/document-updated (ns, revision)` fires on any raw-section change, in-process or external, and `host/settings-changed` now rides it. That event also closes the stale model picker: editing a provider's `models` changes no route, so `llm/adapters-updated` never fired and an open picker kept serving the old catalog. A change to an exposed provider namespace now emits `host/models-changed` too — that namespace holds the catalog. Docs: both sides of the five touched README pairs, a type-equiv block for `SettingsPathOp`, and an Agent Note recording what the plane exposes and who may overwrite what. The deferred wire-redaction gaps (secrets behind union/intersection/transform, `.default(...)` in the served envelope, schema text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are recorded as TODO(settings-wire-redaction) and in Known Limitations rather than half-fixed.
2026-07-30 19:24:21 +08:00
只持有脱敏 descriptor 的调用方无法安全地重建分节,因此删除改以路径 op 传递。每个 descriptor 还携带针对原始分节的 `revision`;写入可以把它作为 `expectedRevision` 送回,不再匹配的写入会被拒绝,而不是覆盖在先落地的那个写方之上。
```ts type-equiv
/**
* One path-addressed edit to a namespace's user section. Path mutation exists
* for a caller holding an INCOMPLETE view of the section — a configuration UI
* reads the redacted descriptor, which by construction never received the
* `role('secret')` fields. Such a caller can name the field it means without
* restating the section: a wholesale `replace` rebuilt from a redacted
* document silently deletes every secret the wire never returned.
*/
type SettingsPathOp =
| { op: 'set'; path: readonly string[]; value: unknown }
| { op: 'unset'; path: readonly string[] }
```
```ts type-equiv
/** Options for {@link Settings.describe}. */
interface SettingsDescribeOptions {
/**
* Strip `role('secret')` fields from `value`/`base`/`user` and enumerate
* them in each descriptor's `secrets`. Every wire surface MUST pass this;
* the verbatim default exists for same-process configuration UIs only.
*/
redactSecrets?: boolean
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
}
```
## 变更提交
每次提交的变更——进程内写入或提供方观察到的外部编辑——在新值成为权威值之后发出 `settings/updated (ns, next, prev, source)`,解析值深相等时绝不发出。source 标记区分两条入口路径。
fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise<void>), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage.
2026-07-28 18:18:34 +08:00
```ts type-equiv
/** Origin of one committed settings change. */
type SettingsUpdateSource = 'update' | 'provider'
```
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis surface
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxsettings--settings-abstract-seam"></a>
### `ctx.settings` — `Settings` (abstract seam)
Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event.
```ts cordis-catalog
/**
* Prepare the provider's user-editable document for a native editor. File
* providers may materialize an absent document before returning its path;
* non-file providers return undefined.
* @returns the absolute local document path, or undefined for non-file storage.
*/
prepareDocument(): Promise<string | undefined>
/**
* Register a namespace schema and receive its owner scope. The registration
* is an effect on the calling plugin's fiber: disposing that fiber removes
* the namespace and its observers. An invalid stored section fails the
* registration itself — the earliest point where the schema can judge it.
* @param ns - unique namespace; duplicate registration fails loud.
* @param schema - schemastery schema resolving this namespace's value.
* @param options - composition `base` layer and effect timing.
* @returns the owner scope for reads, observation, and updates.
*/
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>
/**
* Describe every registered namespace for configuration surfaces, including
* the composition `base` and raw user layers so a form can mark which fields
* the user overrode (presence in `user`) and what a reset returns to.
* @param options - redaction switch; wire surfaces must redact.
* @returns one descriptor per registered namespace, in registration order.
*/
describe(options?: SettingsDescribeOptions): SettingsDescriptor[]
/**
* Read one registered namespace's resolved value.
* @param ns - the namespace to read.
* @returns the resolved value, or `undefined` while unregistered.
*/
get(ns: SettingsNamespace): unknown
/**
* Merge a patch into one registered namespace's user layer, validate the
* resolved candidate, persist through the provider, then commit and emit.
* A validation failure rejects before anything is persisted. Writes to one
* namespace are serialized: concurrent updates apply in call order, each
* merging over the previous write's committed section.
* @param ns - the registered namespace to update.
* @param patch - plain-object patch over the user section.
* @param expectedRevision - the descriptor `revision` the caller read; a
* namespace that moved past it rejects with {@link SettingsConflictError}.
*/
async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise<void>
/**
* Replace one registered namespace's user section wholesale, validate,
* persist, then commit and emit. Keys absent from `section` fall back to the
* composition `base` and schema defaults — this is the removal/reset path a
* merge-only patch cannot express (`replace({})` re-inherits everything).
* @param ns - the registered namespace to replace.
* @param section - the complete next user section.
* @param expectedRevision - the descriptor `revision` the caller read; a
* namespace that moved past it rejects with {@link SettingsConflictError}.
*/
async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise<void>
/**
* Apply path-addressed edits to one registered namespace's user section,
* validate, persist, then commit and emit. The ops are applied to the
* section as it stands when the write reaches the front of the queue, so a
* caller never has to restate fields it did not touch — and, crucially,
* cannot delete fields it never saw. This is the write path for any caller
* holding a redacted view; `replace` remains the wholesale reset.
* @param ns - the registered namespace to edit.
* @param ops - ordered path edits; later ops observe earlier ones.
* @param expectedRevision - the descriptor `revision` the caller read; a
* namespace that moved past it rejects with {@link SettingsConflictError}.
*/
async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void>
```
Source: [`packages/settings/settings/src/index.ts:387`](../../packages/settings/settings/src/index.ts)
<a id="settings-events"></a>
### `settings/*` events
<a id="settingsdocument-updated--emit"></a>
#### `settings/document-updated` — emit
One registered namespace's RAW user section changed, whether or not the resolved value did. `settings/updated` is the consumer-facing event and stays deep-equal-gated; this one exists for configuration surfaces, which must learn that a field went from inherited to overridden (same resolved value, different meaning) and that their held revision is stale. Listener containment matches `settings/updated`.
```ts cordis-catalog
/**
* One registered namespace's RAW user section changed, whether or not the
* resolved value did. `settings/updated` is the consumer-facing event and
* stays deep-equal-gated; this one exists for configuration surfaces,
* which must learn that a field went from inherited to overridden (same
* resolved value, different meaning) and that their held revision is
* stale. Listener containment matches `settings/updated`.
* @param ns - the namespace whose stored section changed.
* @param revision - the namespace's new revision.
* @mode emit
*/
'settings/document-updated'(ns: SettingsNamespace, revision: number): void
```
Source: [`packages/settings/settings/src/index.ts:170`](../../packages/settings/settings/src/index.ts)
<a id="settingsupdated--emit"></a>
#### `settings/updated` — emit
Committed change to one registered namespace's resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
```ts cordis-catalog
/**
* Committed change to one registered namespace's resolved value. Emitted
* after the provider persisted (for `update`) or published (`provider`)
* the change; never emitted when the resolved value is deep-equal.
* Listener failures are contained and logged — a sync throw and an async
* rejection alike — except `INVARIANT`-coded failures, which rethrow
* after every listener ran; that rethrow reaches the emitter only from
* synchronous listeners, so invariant checks on this event must not be
* async functions.
* @param ns - the namespace whose resolved value changed.
* @param next - the new resolved value.
* @param prev - the previous resolved value.
* @param source - whether the change entered through `update()` or the provider.
* @mode emit
*/
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
```
Source: [`packages/settings/settings/src/index.ts:157`](../../packages/settings/settings/src/index.ts)
<!-- END GENERATED cordis-surface -->