Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
_Kerman
2026-08-05 16:21:09 +08:00
71 changed files with 1102 additions and 156 deletions

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: 344300c33879918e836b6e208b172343cc472faa
README.zh.md: 7e4913c0883c48c23de3408a3b0fe0455160984f
README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4
README.zh.md: 3a290c2795e6d1944c5bce6edb99aab53f2728ee

View File

@@ -24,8 +24,9 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
- **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.
- **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document 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.
- **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target.
## Model Experience

View File

@@ -24,8 +24,9 @@
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
- **Dispose 在每种 watch 模式下都保证静止。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
- **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。
## Model Experience

View File

@@ -10,7 +10,7 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
@@ -96,6 +96,11 @@ function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Whether an exclusive file create found an existing document. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/** File-backed settings provider (`settings.yaml`/`.json`). */
export class SettingsLocal extends Settings {
static Config: z<Config> = z.object({
@@ -139,6 +144,29 @@ export class SettingsLocal extends Settings {
return true
}
/** The resolved YAML/JSON document path exposed to local configuration surfaces. */
override get documentPath(): string {
return this.spec.filename
}
/** Materialize an absent owner-only document, then return its resolved path. */
override prepareDocument(): Promise<string> {
return this.enqueue(async () => {
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
try {
await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 })
} catch (error) {
if (isEEXIST(error)) return
throw error
}
this.text = ''
if (!this.isClosed()) this.publish({})
})
return this.spec.filename
})
}
protected async load(): Promise<Record<string, unknown>> {
let text: string
try {
@@ -206,34 +234,36 @@ export class SettingsLocal extends Settings {
// failure: an existing-but-invalid document must fail loud, never be
// silently ignored or overwritten.
yield* super[Service.init]()
if (!this.spec.watch) return
const watcher = chokidarWatch(this.spec.filename, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
},
})
watcher.on('all', () => {
if (this.closed) return
this.queueRefresh()
})
watcher.on('ready', () => {
// The base init's load raced the watcher's own setup: a change written
// between that read and the watcher becoming active never fires an
// event. One reconcile at ready closes the gap.
if (this.closed) return
this.queueRefresh()
})
watcher.on('error', (error) => {
this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
this.ctx.logger.warn(error)
})
const watcher = this.spec.watch
? chokidarWatch(this.spec.filename, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
},
})
: undefined
if (watcher !== undefined) {
watcher.on('all', () => {
if (this.closed) return
this.queueRefresh()
})
watcher.on('ready', () => {
// The base init's load raced the watcher's own setup: a change written
// between that read and the watcher becoming active never fires an
// event. One reconcile at ready closes the gap.
if (this.closed) return
this.queueRefresh()
})
watcher.on('error', (error) => {
this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
this.ctx.logger.warn(error)
})
}
yield async () => {
// Quiesce: stop accepting events, close the watcher, then wait out any
// queued or in-flight operation so nothing publishes after disposal.
// Quiesce every operation chain, even when no watcher is configured.
this.closed = true
await watcher.close()
await watcher?.close()
await this.operations
}
}

View File

@@ -49,12 +49,37 @@ describe('resolveSpec', () => {
describe('boot and reads', () => {
it('resolves defaults over an absent file and reports writable', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
expect(ctx.settings.writable).toBe(true)
expect(ctx.settings.documentPath).toBe(path)
})
it('prepares an absent owner-only document without changing resolved settings', async () => {
const dir = await tempDir()
const path = join(dir, 'nested', 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
expect(await readFile(path, 'utf8')).toBe('')
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('preparing an existing document preserves its contents', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const contents = 'ui-theme:\n theme: light\n'
await writeFile(path, contents)
const ctx = await boot({ path, watch: false })
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
expect(await readFile(path, 'utf8')).toBe(contents)
})
it('reads sections from an existing yaml document', async () => {
@@ -78,6 +103,7 @@ describe('boot and reads', () => {
it('defaults the file location under the configured harness home', async () => {
const dir = await tempDir()
const ctx = await boot({ dshHome: dir, watch: false })
expect(ctx.settings.documentPath).toBe(join(dir, 'settings.yaml'))
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')

View File

@@ -11,6 +11,10 @@ import { SettingsLocal } from '../src/index.ts'
const state = vi.hoisted(() => ({
failTempWrite: false,
failDocumentCreate: false,
holdDocumentCreate: false,
documentCreateStarted: undefined as (() => void) | undefined,
continueDocumentCreate: undefined as Promise<void> | undefined,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
@@ -18,6 +22,15 @@ vi.mock('node:fs/promises', async (importOriginal) => {
return {
...actual,
writeFile: (async (path: unknown, ...rest: never[]) => {
if (state.holdDocumentCreate && String(path).endsWith('settings.yaml')) {
state.holdDocumentCreate = false
state.documentCreateStarted!()
await state.continueDocumentCreate!
}
if (state.failDocumentCreate && String(path).endsWith('settings.yaml')) {
state.failDocumentCreate = false
throw Object.assign(new Error('ENOSPC: injected document create failure'), { code: 'ENOSPC' })
}
if (state.failTempWrite && String(path).endsWith('.tmp')) {
state.failTempWrite = false
throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' })
@@ -33,6 +46,10 @@ const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
state.failTempWrite = false
state.failDocumentCreate = false
state.holdDocumentCreate = false
state.documentCreateStarted = undefined
state.continueDocumentCreate = undefined
while (cleanups.length > 0) await cleanups.pop()!()
})
@@ -51,6 +68,49 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro
}
describe('writer-lock failure cleanup', () => {
it('skips publication when an in-flight document create completes during teardown', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, { path, watch: false })
cleanups.push(async () => { await fiber.dispose() })
await fiber
const settings = ctx.settings
settings.register(settingsNamespace('alpha'), AlphaSchema)
const published: number[] = []
ctx.on('settings/document-updated', (_ns, revision) => { published.push(revision) })
let markStarted!: () => void
const started = new Promise<void>((resolve) => { markStarted = resolve })
let releaseCreate!: () => void
state.continueDocumentCreate = new Promise<void>((resolve) => { releaseCreate = resolve })
state.documentCreateStarted = markStarted
state.holdDocumentCreate = true
const preparing = settings.prepareDocument()
await started
let disposed = false
const disposing = fiber.dispose()
void disposing.then(() => { disposed = true })
await vi.waitFor(() => {
expect((settings as unknown as { closed: boolean }).closed).toBe(true)
})
expect(disposed).toBe(false)
releaseCreate()
await expect(preparing).resolves.toBe(path)
await disposing
expect(await readFile(path, 'utf8')).toBe('')
expect(published).toEqual([])
})
it('surfaces an exclusive document-create failure and releases the lock', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
state.failDocumentCreate = true
await expect(ctx.settings.prepareDocument()).rejects.toThrow(/ENOSPC/)
await expect(access(`${path}.lock`)).rejects.toThrow()
})
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')

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: 1f1ce07722bfb035746ad5733f90ddabe2d1553b
README.zh.md: 0d96a0deda3b9d8f6260a1f223eb86cb87781565
README.md: 0841624e553364fa03f7a1f1209aa72ab13d97e4
README.zh.md: fa84ca198ba7383f8fbd7b29a53d74f2c375bd5e

View File

@@ -6,6 +6,8 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
## Service API
- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Host configuration adapters derive availability from it, while browser protocols expose only a boolean capability and never a filesystem target.
- `prepareDocument()` — return that path after making the document ready for a native editor. The base implementation returns `documentPath`; a file provider may materialize an absent document first.
- `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(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires.
- `get(ns)` — resolved value, `undefined` while unregistered.
@@ -18,7 +20,7 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
## Provider contract
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, optionally override `documentPath` and `prepareDocument()` for one local user-editable file, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
## Events

View File

@@ -6,6 +6,8 @@
## 服务 API
- `documentPath` — 提供方拥有用户可编辑文件时,该字段是文件的绝对路径;非文件提供方保留 `undefined`。Host 配置适配器据此派生可用性,而浏览器协议只暴露一个布尔能力,绝不暴露文件系统目标。
- `prepareDocument()` — 让文档做好供原生编辑器打开的准备后返回该路径。基类实现返回 `documentPath`;文件提供方可先创建缺失的文档。
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
- `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个 wire 面都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。
- `get(ns)` — 解析值;未注册时为 `undefined`。
@@ -18,7 +20,7 @@
## Provider 契约
子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
子类实现 `writable`、`load()`、`persist(ns, section)`,可选择为一个本地用户可编辑文件重写 `documentPath` 与 `prepareDocument()`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
## 事件

View File

@@ -403,6 +403,27 @@ export abstract class Settings extends Service {
/** Whether {@link update} may persist through this provider. */
abstract readonly writable: boolean
/**
* Absolute path of the provider's user-editable document, when its storage
* is one local file. Configuration surfaces use this only as availability
* metadata; the guarded open operation resolves the path again Host-side.
* Non-file providers leave it undefined and expose no open-document affordance.
* @returns the absolute local document path, or undefined for non-file storage.
*/
get documentPath(): string | undefined {
return undefined
}
/**
* 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> {
return Promise.resolve(this.documentPath)
}
/**
* Read the provider's current raw document (namespace to raw section).
* @returns the detached raw document.

View File

@@ -58,6 +58,14 @@ async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
return { ctx, provider, fiber }
}
describe('provider metadata', () => {
it('does not advertise a local document unless the provider overrides it', async () => {
const { ctx } = await boot()
expect(ctx.settings.documentPath).toBeUndefined()
await expect(ctx.settings.prepareDocument()).resolves.toBeUndefined()
})
})
/** Record every settings/updated emission. */
function recordUpdates(ctx: Context) {
const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []