feat(settings): add user-settings seam (ctx.settings) + file provider

Two-package capability family mirroring session-persistence/:

- dsh-settings: abstract Settings service — namespace registry with
  caller-fiber effect registrations, layered resolution (schema defaults
  < composition base < user document), schemastery validation,
  per-namespace deep-equal commit detection, and the settings/updated
  event. Boot/registration validation fails loud; provider publishes
  keep last-good per namespace.
- dsh-settings-local: settings.yaml/.json provider — resolveSpec
  defaulting to $DSH_HOME/settings.yaml, chokidar hot reload,
  content-equality self-write suppression, atomic 0600 tmp+rename
  writes, comment-preserving YAML namespace patching.

Consumers register inside ctx.inject(['settings'], …), so every
composition works unchanged without a mounted provider. Real Loader +
Include composition test proves cordis.yml boot and external-edit hot
propagation; HMR disposal test proves registry cleanup. Both packages
hold per-file 100% coverage.

Doc budgets rise 1705→1710 (AGENTS.md) and 835→845 (packages/README.md):
one structural line per file for the new package group.

Agent Note: .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md
This commit is contained in:
Yichen Jiang
2026-07-28 17:30:12 +08:00
parent f63d2deecf
commit ec0786e099
43 changed files with 2098 additions and 4 deletions

View File

@@ -0,0 +1,112 @@
/**
* Real-composition guard: the provider and a consumer plugin boot from a
* test-only cordis.yml through the actual Loader + Include path, and an
* external edit of settings.yaml hot-publishes into the consumer's scope.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import z from 'schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import SettingsLocal from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
interface ConsumerState {
scope: SettingsScope<ThemeConfig> | undefined
seen: ThemeConfig[]
}
async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
const state: ConsumerState = { scope: undefined, seen: [] }
const consumer = {
name: 'settings-consumer',
inject: ['settings'],
apply: (ctx: Context) => {
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
state.scope = scope
scope.watch(next => state.seen.push(next))
},
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
'- id: consumer',
' name: test-settings-consumer',
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-settings-local', SettingsLocal],
['test-settings-consumer', consumer],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, state, settingsPath }
}
describe('settings-local real composition', () => {
it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
const { ctx, state, settingsPath } = await loadComposition()
// Composition resolution: user layer over the consumer's composition base.
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
})
})

View File

@@ -0,0 +1,254 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal, resolveSpec } from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('resolveSpec', () => {
it('defaults watch and debounce when construction bypasses schema normalization', () => {
const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
expect(spec.watch).toBe(true)
expect(spec.debounceMs).toBe(100)
})
})
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 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)
})
it('reads sections from an existing yaml document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
})
it('reads sections from a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
it('defaults the file location under the configured harness home', async () => {
const dir = await tempDir()
const ctx = await boot({ dshHome: dir, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
expect(written).toContain('theme: light')
})
it('reads an empty yaml document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('reads an empty json document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('fails loud at boot when the document exists but is unreadable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
})
it('fails loud on an unsupported extension', async () => {
const dir = await tempDir()
await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
.rejects.toThrow(/not supported/)
})
it('fails loud at boot on unparsable yaml', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme: [unclosed\n')
await expect(boot({ path, watch: false })).rejects.toThrow()
})
it('fails loud at boot when the root is not a map of sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '- just\n- a list\n')
await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
})
})
describe('persist', () => {
it('writes the merged section, creating the file with owner-only permissions', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(path, 'utf8')
expect(written).toContain('theme: light')
expect((await stat(path)).mode & 0o777).toBe(0o600)
// Atomic replace leaves no temp artifact behind.
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
})
it('preserves comments and unregistered sections across updates', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'# personal settings',
'ui-theme:',
' theme: light',
'# owned by a plugin that is not loaded right now',
'future-plugin:',
' keep: me',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ fontSize: 18 })
const written = await readFile(path, 'utf8')
expect(written).toContain('# personal settings')
expect(written).toContain('# owned by a plugin that is not loaded right now')
expect(written).toContain('keep: me')
expect(written).toContain('fontSize: 18')
expect(written).toContain('theme: light')
})
it('creates a json document from scratch', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
})
it('round-trips a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
})
})
describe('watch', () => {
it('publishes an external edit to registered scopes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get().theme).toBe('light')
await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
})
it('keeps the last good document over an invalid edit, then recovers', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await writeFile(path, 'ui-theme: [unclosed\n')
// The bad edit must never take the live tree down or reset the value.
await new Promise(resolve => setTimeout(resolve, 300))
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
await writeFile(path, 'ui-theme:\n theme: dark\n')
await vi.waitFor(() => {
expect(scope.get().theme).toBe('dark')
}, { timeout: 5000 })
})
it('treats file removal as an empty document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await rm(path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
}, { timeout: 5000 })
})
it('does not republish its own persisted write', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 10 })
const events: unknown[] = []
ctx.on('settings/updated', (ns, _next, _prev, source) => {
events.push({ ns, source })
})
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
await new Promise(resolve => setTimeout(resolve, 300))
expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
})
})

View File

@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '../src/index.ts'
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
vi.mock('chokidar', async () => {
const { EventEmitter } = await import('node:events')
class FakeWatcher extends EventEmitter {
close = vi.fn(() => Promise.resolve())
}
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
return {
watch: vi.fn((path: string, options: unknown) => {
const watcher = new FakeWatcher()
instances.push({ path, options, watcher })
return watcher
}),
__instances: instances,
}
})
interface FakeChokidar {
__instances: Array<{
path: string
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
watcher: import('node:events').EventEmitter
}>
}
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
const chokidar = await import('chokidar') as unknown as FakeChokidar
return chokidar.__instances
}
const ThemeSchema: z<{ theme: string }> = z.object({
theme: z.string().default('dark'),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
;(await fakeInstances()).length = 0
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(scope.get()).toEqual({ theme: 'dark' })
await writeFile(path, 'ui-theme:\n theme: light\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'light' })
})
})
it('keeps the last good document when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'light' })
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'dark' })
})
})