From ec0786e099e487526785e4bdf870868ed640e9aa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 28 Jul 2026 17:30:12 +0800 Subject: [PATCH] feat(settings): add user-settings seam (ctx.settings) + file provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-07-28-user-settings-seam.i18n.yaml | 6 + .../2026-07-28-user-settings-seam.md | 35 ++ .../2026-07-28-user-settings-seam.zh.md | 35 ++ AGENTS.md | 1 + docs/capability-seams.md | 6 + docs/config-catalog.md | 19 ++ docs/cordis-catalog/events.md | 22 ++ docs/cordis-catalog/services.md | 42 +++ docs/event-producer-consumer.md | 1 + packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 49 +++ packages/settings/README.i18n.yaml | 6 + packages/settings/README.md | 12 + packages/settings/README.zh.md | 12 + .../settings/settings-local/README.i18n.yaml | 6 + packages/settings/settings-local/README.md | 36 ++ packages/settings/settings-local/README.zh.md | 36 ++ packages/settings/settings-local/package.json | 46 +++ packages/settings/settings-local/src/index.ts | 226 +++++++++++++ .../settings/settings-local/src/invariant.ts | 31 ++ .../tests/loader-composition.spec.ts | 112 +++++++ .../settings-local/tests/local.spec.ts | 254 +++++++++++++++ .../settings-local/tests/watcher.spec.ts | 118 +++++++ .../settings/settings-local/tsconfig.json | 30 ++ packages/settings/settings/README.i18n.yaml | 6 + packages/settings/settings/README.md | 35 ++ packages/settings/settings/README.zh.md | 35 ++ packages/settings/settings/package.json | 41 +++ packages/settings/settings/src/index.ts | 304 +++++++++++++++++ packages/settings/settings/src/invariant.ts | 41 +++ .../settings/settings/tests/invariant.spec.ts | 41 +++ packages/settings/settings/tests/memory.ts | 53 +++ .../settings/settings/tests/settings.spec.ts | 307 ++++++++++++++++++ packages/settings/settings/tsconfig.json | 27 ++ pnpm-lock.yaml | 40 +++ scripts/doc-budgets.manifest.json | 4 +- scripts/gen-cordis-catalog.ts | 6 + scripts/gen-doc-graphs.ts | 9 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + 43 files changed, 2098 insertions(+), 4 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md create mode 100644 packages/settings/README.i18n.yaml create mode 100644 packages/settings/README.md create mode 100644 packages/settings/README.zh.md create mode 100644 packages/settings/settings-local/README.i18n.yaml create mode 100644 packages/settings/settings-local/README.md create mode 100644 packages/settings/settings-local/README.zh.md create mode 100644 packages/settings/settings-local/package.json create mode 100644 packages/settings/settings-local/src/index.ts create mode 100644 packages/settings/settings-local/src/invariant.ts create mode 100644 packages/settings/settings-local/tests/loader-composition.spec.ts create mode 100644 packages/settings/settings-local/tests/local.spec.ts create mode 100644 packages/settings/settings-local/tests/watcher.spec.ts create mode 100644 packages/settings/settings-local/tsconfig.json create mode 100644 packages/settings/settings/README.i18n.yaml create mode 100644 packages/settings/settings/README.md create mode 100644 packages/settings/settings/README.zh.md create mode 100644 packages/settings/settings/package.json create mode 100644 packages/settings/settings/src/index.ts create mode 100644 packages/settings/settings/src/invariant.ts create mode 100644 packages/settings/settings/tests/invariant.spec.ts create mode 100644 packages/settings/settings/tests/memory.ts create mode 100644 packages/settings/settings/tests/settings.spec.ts create mode 100644 packages/settings/settings/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml new file mode 100644 index 0000000000..cc409d8403 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md +2026-07-28-user-settings-seam.md: f0f45d77c8f98fc15625b1a1bf116ec10b965676 +2026-07-28-user-settings-seam.zh.md: eb562099b1ff0b5b0a019e9956d6e36e71857236 diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md new file mode 100644 index 0000000000..f0f45d77c8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md @@ -0,0 +1,35 @@ +# Agent Note: user-settings seam (`ctx.settings`) and the file provider + +Status: implemented + +English | [中文](2026-07-28-user-settings-seam.zh.md) + +> Scope: the `packages/settings/` capability family — the abstract seam, the file-backed provider, and the composition boundary between user settings and `cordis.yml`. The [web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) recorded "the profile write path" as a deferral; this seam is that write path's owner. Consumer migrations (theme, locale, default model route) and the web `settings.*` RPC surface are follow-ups, not part of this note's shipped scope. + +## Problem + +User-editable configuration had no owner: `dsh web` read a cwd-anchored profile json through a static whitelist with no write path, the TUI read `$DSH_HOME/config.yaml` raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on separating user preferences from extension composition. Loader-reactive config updates cannot carry this: `fiber.update` swaps entry config in place, so a plugin that read config at construction observes nothing and no callback tells it otherwise. + +## Decision + +**Two planes with a litmus test.** `cordis.yml` (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition `base` (its entry-config subset), then the user document section. + +**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `/settings.yaml`), chokidar watch, atomic `0600` tmp+rename writes, comment-preserving YAML patching of exactly one namespace key, and content-equality self-write suppression. + +**Registrations are caller-fiber effects.** `register()` runs through the service proxy, so `this.ctx` is the registrant's context and the registration rides `ctx.effect`: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner. + +**Fail loud at rest, last-good in motion.** Boot-time and registration-time validation throw (invalid stored section fails the registering plugin; an existing-but-unparsable document fails provider load). Once live, a bad external edit warns and keeps the last good state per namespace — a hot reload must never take the process down. This asymmetry mirrors `Include.refresh()` and Kimi's safe runtime reload. + +**Consumers stay optional-by-construction.** A consumer registers inside `ctx.inject(['settings'], …)`; without a mounted provider it keeps resolving entry config alone, so every existing composition, demo, and snapshot works unchanged and migration is per-plugin. + +## Alternatives considered + +- **Include write-back as the user layer** (per-plugin config pages writing loader entry files, cordis-webui style): write-back would target per-composition files, binding user preferences to one `cordis.yml`; a per-user layer must survive template upgrades and serve TUI and web from one document. +- **Loader-reactive `fiber.update` as the propagation channel**: constructor-time reads observe nothing; the seam's explicit `watch()` makes hot-update a consumer contract instead of framework magic. +- **A domain-aware settings service** (getters per product area): the coupling objection from design review stands; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema. +- **Multi-layer precedence now** (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend. +- **A cross-process lockfile now** (Pi's proper-lockfile): atomic replace plus watcher convergence (last write wins) is documented behavior until real contention shows up. + +## Consequences + +Deferred, in dependency order: the web `settings.raw`/`settings.describe`/`settings.update` RPC surface (which must redact `role('secret')` fields before exposure); first consumer migrations (`ui-theme`, locale, api-gateway default route) retiring `PROFILE_MAPPINGS` and the profile json; `${env:VAR}` value indirection for secrets; provider-side layering. The keyless snapshot obligation lands with the first model- or product-user-visible consumer, not with this infrastructure step. diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md new file mode 100644 index 0000000000..eb562099b1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md @@ -0,0 +1,35 @@ +# Agent Note:用户设置 seam(`ctx.settings`)与文件 provider + +Status: implemented + +[English](2026-07-28-user-settings-seam.md) | 中文 + +> 范围:`packages/settings/` 能力族——抽象 seam、文件 provider,以及用户设置与 `cordis.yml` 的组合边界。[web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) 曾把"profile 写路径"记为延后项;本 seam 就是该写路径的归属。消费者迁移(主题、语言、默认模型路由)与 web `settings.*` RPC 面是后续工作,不在本 note 已交付范围内。 + +## 问题 + +用户可编辑配置没有归属:`dsh web` 经静态白名单读 cwd 锚定的 profile json 且无写路径,TUI 读 `$DSH_HOME/config.yaml` 裸 loader patch,两者都在启动时冻结。个人设置页(web GUI)需要一个跨 surface 的用户层,带 schema 校验、写路径与热传导——同类产品(Codex、Claude Code、Kimi、OpenCode、Pi)也全部收敛于"用户偏好与扩展组合分离"。Loader 的 reactive 配置更新承载不了这件事:`fiber.update` 原地替换 entry config,构造期读过配置的插件毫无感知,也没有任何回调通知它。 + +## 决策 + +**两个面,一条判定。**`cordis.yml`(+ Include patches)仍是组合面:有哪些插件、接线、部署配置,归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集;判定是"个人配置页应该能改它吗?"值可同时存在于两个面而不歧义,因为分层就是契约:schema 默认值,然后注册方的组合 `base`(其 entry 配置子集),最后用户文档分节。 + +**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `/settings.yaml` 的 YAML/JSON、chokidar 监听、`0600` tmp+rename 原子写、只修补目标 namespace 键的保注释 YAML 写回、按内容相等抑制自写。 + +**注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用,`this.ctx` 即注册方 context,注册挂在 `ctx.effect` 上:dispose 注册方即移除 namespace 及其观察者(HMR disposal 测试证明),而用户的分节继续留在存储中等待下一任 owner。 + +**静止时响亮报错,运行中保留最后可用值。**启动期与注册期校验直接抛错(非法存量分节使注册插件加载失败;存在但不可解析的文档使 provider 加载失败)。运行中坏的外部编辑只告警并按 namespace 保留最后可用状态——热重载绝不拖垮进程。该不对称镜像 `Include.refresh()` 与 Kimi 的安全运行时重载。 + +**消费者天然可选。**消费者在 `ctx.inject(['settings'], …)` 内注册;不挂 provider 时仍只按 entry 配置解析,因此所有既有组合、demo、snapshot 原样工作,迁移按插件渐进。 + +## Alternatives considered + +- **以 Include 写回为用户层**(cordis-webui 式的按插件配置页写 loader entry 文件):写回目标是按组合的文件,会把用户偏好绑死在某个 `cordis.yml` 上;用户层必须在模板升级中存活,并以同一文档服务 TUI 与 web。 +- **以 Loader reactive `fiber.update` 为传导通道**:构造期读取毫无感知;seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。 +- **领域化的 settings 服务**(按产品域的 getter):设计评审中的耦合反对成立;服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。 +- **现在就做多层优先级**(Codex/Claude Code 式 system/managed/project 层级):延后到真实第二层出现;resolve 步骤是分层未来唯一的扩展点。 +- **现在就上跨进程锁**(Pi 的 proper-lockfile):原子替换加 watcher 收敛(后写胜出)是已记录的行为,真实冲突出现再说。 + +## 后果 + +按依赖顺序延后:web `settings.raw`/`settings.describe`/`settings.update` RPC 面(暴露前必须对 `role('secret')` 字段脱敏);首批消费者迁移(`ui-theme`、语言、api-gateway 默认路由)并退役 `PROFILE_MAPPINGS` 与 profile json;面向密钥的 `${env:VAR}` 值间接引用;provider 侧分层。keyless snapshot 义务随第一个模型或产品用户可见的消费者落地,而非本基础设施步骤。 diff --git a/AGENTS.md b/AGENTS.md index c987801c17..7112dd3aee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code/Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends + settings/ user-settings seam + file-backed provider acp/ automation-only Agent Client Protocol server ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9093f41f15..74f7c63c0d 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -35,6 +35,9 @@ flowchart LR pkg_tool_bash["tool-bash"] pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] + pkg_settings["settings"] + svc_settings["ctx.settings
User-settings seam"] + pkg_settings_local["settings-local"] pkg_session_telemetry["session-telemetry"] svc_telemetry["ctx.telemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -188,6 +191,8 @@ flowchart LR pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle + pkg_settings --> svc_settings + pkg_settings_local --> svc_settings pkg_skill --> svc_skills pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore @@ -316,6 +321,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dcb4fa69e7..1305dd7270 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1177,6 +1177,24 @@ Depends on: [`SessionTitleLlmConfig`](../packages/session-title/session-title-ll Source: [`packages/session-title/session-title-first-message-llm/src/index.ts:15`](../packages/session-title/session-title-first-message-llm/src/index.ts) +## `@deepseek-ai/dsh-settings-local` + +```ts config-catalog +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Settings document path; defaults to `settings.yaml` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} +``` + +Source: [`packages/settings/settings-local/src/index.ts:18`](../packages/settings/settings-local/src/index.ts) + ## `@deepseek-ai/dsh-skill` ```ts config-catalog @@ -2192,6 +2210,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-settings` — abstract `Settings` ([`packages/settings/settings/src/index.ts`](../packages/settings/settings/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ca0e9b82fc..aea04f4acd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -640,6 +640,28 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) +## `settings/*` + +### `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. + +```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. + * @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:90`](../../packages/settings/settings/src/index.ts) + ## `slash/*` ### `slash/input-begin-command` — bail diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 19d7d7765d..a0271083d6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1388,6 +1388,48 @@ Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](. Source: [`packages/session-title/session-title/src/index.ts:232`](../../packages/session-title/session-title/src/index.ts) +## `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 +/** + * 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(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope + +/** + * Describe every registered namespace for configuration surfaces. + * @returns one descriptor per registered namespace, in registration order. + */ +describe(): 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. + * @param ns - the registered namespace to update. + * @param patch - plain-object patch over the user section. + */ +async update(ns: SettingsNamespace, patch: object): Promise +``` + +Source: [`packages/settings/settings/src/index.ts:140`](../../packages/settings/settings/src/index.ts) + ## `ctx.skills` — `SkillService` Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e9e5bb6e6a..49bdf3fe35 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,6 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:90`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`emit`) | [`settings`](../packages/settings/settings) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 5dd168f03e..360687f197 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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/README.md -README.md: d16e395a42e491461c0862227205931894c27e39 -README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f +README.md: 7b415bdbbd3e442c952da49b6f9f8823a636f643 +README.zh.md: d3347ecd6db68d99bc00e340c632da51cf825c63 diff --git a/packages/README.md b/packages/README.md index d16e395a42..7b415bdbbd 100644 --- a/packages/README.md +++ b/packages/README.md @@ -37,6 +37,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`settings/`](settings/README.md) | User-settings capability family: the seam + file-backed provider | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 3fb4181ce7..d3347ecd6d 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -37,6 +37,7 @@ | [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 | +| [`settings/`](settings/README.md) | 用户设置能力族:seam + 文件 provider | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 47f8a1a54f..81e3c79d05 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -666,6 +666,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'settings', + summary: 'Abstract settings service.', + methods: [ + { + signature: 'register(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope', + jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */', + }, + { + signature: 'describe(): SettingsDescriptor[]', + jsDoc: '/**\n * Describe every registered namespace for configuration surfaces.\n * @returns one descriptor per registered namespace, in registration order.\n */', + }, + { + signature: 'get(ns: SettingsNamespace): unknown', + jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */', + }, + { + signature: 'async update(ns: SettingsNamespace, patch: object): Promise', + jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */', + }, + ], + }, { key: 'skills', summary: 'Registry of skill providers.', @@ -1188,6 +1210,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, + { + 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 */', + summary: 'Committed change to one registered namespace\'s resolved value.', + }, { name: 'slash/input-begin-command', mode: 'bail', @@ -2181,6 +2210,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleUserMessage', declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}', }, + { + name: 'SettingsApplies', + declaration: 'export type SettingsApplies = \'live\' | \'restart\';', + }, + { + name: 'SettingsDescriptor', + declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n applies: SettingsApplies;\n}', + }, + { + name: 'SettingsNamespace', + declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;', + }, + { + name: 'SettingsRegisterOptions', + declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n}', + }, + { + name: 'SettingsScope', + declaration: 'export interface SettingsScope {\n get(): T;\n watch(callback: (next: T, prev: T) => void): () => void;\n update(patch: object): Promise;\n}', + }, { name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', diff --git a/packages/settings/README.i18n.yaml b/packages/settings/README.i18n.yaml new file mode 100644 index 0000000000..7c78505615 --- /dev/null +++ b/packages/settings/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/README.md +README.md: 7a91355dd01805938944f0abce77765021288e6d +README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0 diff --git a/packages/settings/README.md b/packages/settings/README.md new file mode 100644 index 0000000000..7a91355dd0 --- /dev/null +++ b/packages/settings/README.md @@ -0,0 +1,12 @@ +# settings/ — user-settings capability family + +English | [中文](README.zh.md) + +The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` | +| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) | + +The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document. diff --git a/packages/settings/README.zh.md b/packages/settings/README.zh.md new file mode 100644 index 0000000000..2df40b67eb --- /dev/null +++ b/packages/settings/README.zh.md @@ -0,0 +1,12 @@ +# settings/ — 用户设置能力族 + +[English](README.md) | 中文 + +用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。 + +| 包 | 角色 | ctx key | +|---|---|---| +| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` | +| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) | + +接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。 diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml new file mode 100644 index 0000000000..f7ca57e86e --- /dev/null +++ b/packages/settings/settings-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 90428f98055d8e49fa1ec54e571453db2e0a5054 +README.zh.md: 3532d6cee99cb46f54e23889ef1bb54b2548ecfa diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md new file mode 100644 index 0000000000..90428f9805 --- /dev/null +++ b/packages/settings/settings-local/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-settings-local + +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. + +## Config + +| Field | Meaning | Default | +|---|---|---| +| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home | +| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` | +| `watch` | Watch the document and hot-publish external edits | `true` | +| `debounceMs` | Watcher write-settle window in milliseconds | `100` | + +Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load. + +## 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 and owner-only.** `persist` writes `.tmp` with mode `0600` and renames over the target. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes. +- **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 + +Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect. + +#### KV Cache effect + +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. +- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md new file mode 100644 index 0000000000..3532d6cee9 --- /dev/null +++ b/packages/settings/settings-local/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-settings-local + +[English](README.md) | 中文 + +文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 原子写回,并保留用户的 YAML 注释以及当前未加载插件所拥有的分节。 + +## 配置 + +| 字段 | 含义 | 默认 | +|---|---|---| +| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json`) | harness home 下的 `settings.yaml` | +| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME` 或 `~/.dsh` | +| `watch` | 监听文档并热发布外部编辑 | `true` | +| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` | + +默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。 + +## 行为 + +- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 +- **写回原子且仅属主可读。** `persist` 以 `0600` 权限写 `.tmp` 后 rename 覆盖目标。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。 +- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 + +## Model Experience + +间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。 + +#### KV Cache effect + +无直接失效;请求前缀的变更由消费插件拥有。 + +## Known Limitations and Deferred Work + +- **无跨进程写锁** — 并发写入者(例如同一 home 上的 TUI 与 web)靠原子替换加 watcher 重载收敛,后写胜出;lockfile 等真实冲突出现再做。 +- **注释保留仅限 YAML** — JSON 文档重新序列化,无注释(JSON 本身没有)且丢失手工排版。 +- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。 diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json new file mode 100644 index 0000000000..aefb1ccd33 --- /dev/null +++ b/packages/settings/settings-local/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-settings-local", + "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "chokidar": "^4.0.3", + "schemastery": "^3.18.0", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts new file mode 100644 index 0000000000..421411ea50 --- /dev/null +++ b/packages/settings/settings-local/src/index.ts @@ -0,0 +1,226 @@ +/** + * 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. + * @module @deepseek-ai/dsh-settings-local + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { watch as chokidarWatch } from 'chokidar' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, extname, join, resolve } from 'node:path' +import { Document, parseDocument } from 'yaml' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings' + +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Settings document path; defaults to `settings.yaml` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} + +/** Document format derived from the configured file extension. */ +type SettingsFormat = 'yaml' | 'json' + +const FORMATS: Record = { + '.yaml': 'yaml', + '.yml': 'yaml', + '.json': 'json', +} + +/** Fully resolved provider parameters; defaulting happens here, never inline. */ +interface ResolvedSpec { + filename: string + format: SettingsFormat + watch: boolean + debounceMs: number +} + +/** + * Resolve the runtime spec from plugin config: an explicit `path` wins, + * otherwise the document lives at `/settings.yaml`. + * @param config - raw plugin config. + * @returns the resolved file location, format, and watch behavior. + */ +export function resolveSpec(config: Config): ResolvedSpec { + const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml')) + const format = FORMATS[extname(filename)] + if (format === undefined) { + throw new Error(`settings-local: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`) + } + return { + filename, + format, + watch: config.watch ?? true, + debounceMs: config.debounceMs ?? 100, + } +} + +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** File-backed settings provider (`settings.yaml`/`.json`). */ +export class SettingsLocal extends Settings { + static Config: z = z.object({ + path: z.string(), + dshHome: z.string(), + watch: z.boolean().default(true), + debounceMs: z.number().min(0).default(100), + }) + + private readonly spec: ResolvedSpec + /** + * Raw text of the last successfully parsed or persisted document; + * `undefined` while the file is absent. Watcher events whose content equals + * this cache are no-ops, which is also the self-write suppression. + */ + private text: string | undefined + /** Serializes watcher-triggered reloads so reads never interleave. */ + private refreshTask: Promise = Promise.resolve() + + constructor(ctx: Context, public config: Config) { + super(ctx) + // Programmatic construction may bypass Schemastery normalization; resolve + // the same defaults in one explicit step either way. + this.spec = resolveSpec(config) + } + + /** The local document is always writable through {@link Settings.update}. */ + get writable(): boolean { + return true + } + + protected async load(): Promise> { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + this.text = undefined + return {} + } + const doc = this.parse(text) + this.text = text + return doc + } + + protected async persist(ns: SettingsNamespace, section: Record): Promise { + const output = this.spec.format === 'yaml' + ? this.renderYaml(ns, section) + : this.renderJson(ns, section) + await mkdir(dirname(this.spec.filename), { recursive: true }) + const temp = `${this.spec.filename}.tmp` + // Owner-only permissions apply to the temp file and survive the rename, so + // a document that may carry personal values is never world-readable. + await writeFile(temp, output, { mode: 0o600 }) + await rename(temp, this.spec.filename) + this.text = output + } + + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + // A parse failure here is a boot failure: an existing-but-invalid document + // must fail loud, never be silently ignored or overwritten. + this.publish(await this.load()) + 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', () => { + this.refreshTask = this.refreshTask.then(() => this.refresh()) + }) + watcher.on('error', (error) => { + this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename) + this.ctx.logger.warn(error) + }) + yield () => watcher.close() + } + + /** Parse one document text into raw sections, failing on a non-map root. */ + private parse(text: string): Record { + let root: unknown + if (this.spec.format === 'yaml') { + const document = parseDocument(text, { prettyErrors: true }) + if (document.errors.length > 0) { + throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ + document.errors.map(error => error.message).join('; ')}`) + } + root = document.toJS() ?? {} + } else { + root = text.trim().length === 0 ? {} : JSON.parse(text) + } + if (typeof root !== 'object' || root === null || Array.isArray(root)) { + throw new TypeError(`settings-local: ${this.spec.filename} must be a map of namespace sections`) + } + return root as Record + } + + /** + * Re-read the document after a watcher event. Unchanged content (including + * this provider's own writes) is a no-op; an unreadable or unparsable + * document keeps the last good sections and warns — a live hot-reload must + * never take the process down. + */ + private async refresh(): Promise { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) { + this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + return + } + if (this.text === undefined) return + this.text = undefined + this.publish({}) + return + } + if (text === this.text) return + let doc: Record + try { + doc = this.parse(text) + } catch (error) { + this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + return + } + this.text = text + this.publish(doc) + } + + /** Render the next YAML text by patching one namespace in the comment-preserving document. */ + private renderYaml(ns: SettingsNamespace, section: Record): string { + if (this.text === undefined) { + return new Document({ [ns]: section }).toString() + } + // this.text only ever caches content that parsed successfully, so this + // re-parse (for the mutable comment-preserving tree) cannot fail. + const document = parseDocument(this.text) + document.set(ns, section) + return document.toString() + } + + /** Render the next JSON text by replacing one namespace key. */ + private renderJson(ns: SettingsNamespace, section: Record): string { + const root = this.text === undefined + ? {} + : this.parse(this.text) + root[ns] = section + return `${JSON.stringify(root, null, 2)}\n` + } +} + +export default SettingsLocal diff --git a/packages/settings/settings-local/src/invariant.ts b/packages/settings/settings-local/src/invariant.ts new file mode 100644 index 0000000000..b59b798298 --- /dev/null +++ b/packages/settings/settings-local/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`. + * @module @deepseek-ai/dsh-settings-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local' + +/** Cordis companion plugin name. */ +export const name = 'settings-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this provider's contracts are file round-trip, + * watcher timing, and atomic-write behavior — IO effects proven by package + * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/settings/settings-local/tests/loader-composition.spec.ts b/packages/settings/settings-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..5744e92f46 --- /dev/null +++ b/packages/settings/settings-local/tests/loader-composition.spec.ts @@ -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 = 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 | 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([ + ['@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 + 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 }) + }) +}) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts new file mode 100644 index 0000000000..9c9d472bac --- /dev/null +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -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 = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + 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 + 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 + 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' }]) + }) +}) diff --git a/packages/settings/settings-local/tests/watcher.spec.ts b/packages/settings/settings-local/tests/watcher.spec.ts new file mode 100644 index 0000000000..00c67eacc7 --- /dev/null +++ b/packages/settings/settings-local/tests/watcher.spec.ts @@ -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 }> = [] + 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 { + 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> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + ;(await fakeInstances()).length = 0 +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + 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' }) + }) +}) diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json new file mode 100644 index 0000000000..67a746c982 --- /dev/null +++ b/packages/settings/settings-local/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/paths" + }, + { + "path": "../settings" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml new file mode 100644 index 0000000000..51f30f2ca6 --- /dev/null +++ b/packages/settings/settings/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: e57db00c095a87f9f0b51397e030dec364f48e62 +README.zh.md: 8733823106f0ef3880cbe2c567de2edcdeff2c86 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md new file mode 100644 index 0000000000..e57db00c09 --- /dev/null +++ b/packages/settings/settings/README.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-settings + +English | [中文](README.zh.md) + +Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings. + +## Service API + +- `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 update. +- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures are contained. + +## Provider contract + +Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. 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 + +`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value. + +## Model Experience + +Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect. + +#### KV Cache effect + +No direct invalidation; a consumer that folds a settings value into the request prefix owns that change. + +## 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 nothing across processes; concurrent writers converge by provider behavior (the local file provider is last-write-wins). +- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md new file mode 100644 index 0000000000..8733823106 --- /dev/null +++ b/packages/settings/settings/README.zh.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-settings + +[English](README.md) | 中文 + +抽象用户设置 seam(`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。 + +## 服务 API + +- `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`)拒绝一切更新。 +- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`,观察者异常被隔离。 + +## Provider 契约 + +子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。 + +## 事件 + +`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。 + +## Model Experience + +间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。 + +#### KV Cache effect + +无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。 + +## Known Limitations and Deferred Work + +- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。 +- **跨进程并发由 provider 定义** — seam 不做跨进程串行化;并发写入者按 provider 行为收敛(本地文件 provider 为后写胜出)。 +- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。 diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json new file mode 100644 index 0000000000..7586b4bb24 --- /dev/null +++ b/packages/settings/settings/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-settings", + "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" + } +} diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts new file mode 100644 index 0000000000..bec60dfdea --- /dev/null +++ b/packages/settings/settings/src/index.ts @@ -0,0 +1,304 @@ +/** + * User-settings seam (`ctx.settings`). Providers store one raw document of + * per-namespace sections; plugins register a namespace schema and read the + * resolved value, which layers schema defaults, the registrant's composition + * `base`, and the user document section, in that order. + * @module @deepseek-ai/dsh-settings + */ + +import { Context, Service } from 'cordis' +import { deepEqual } from 'cosmokit' +import type z from 'schemastery' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal id of one registered settings namespace. */ +export type SettingsNamespace = Branded<'SettingsNamespace'> + +const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ + +/** + * Brand a raw string as a {@link SettingsNamespace}. + * @param value - candidate namespace; lowercase kebab-case, as in plugin short names. + * @returns the branded namespace. + */ +export function settingsNamespace(value: string): SettingsNamespace { + if (!NAMESPACE_PATTERN.test(value)) { + throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`) + } + return value as SettingsNamespace +} + +/** When a namespace's changes take effect for its owner. */ +export type SettingsApplies = 'live' | 'restart' + +/** Origin of one committed settings change. */ +export type SettingsUpdateSource = 'update' | 'provider' + +/** Registration options beyond the namespace schema. */ +export interface SettingsRegisterOptions { + /** Composition-layer values resolved below the user layer (entry-config subset). */ + base?: Partial + /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ + applies?: SettingsApplies +} + +/** One registered namespace as surfaced to configuration UIs. */ +export interface SettingsDescriptor { + /** The registered namespace. */ + ns: SettingsNamespace + /** Serialized schemastery schema (`schema.toJSON()`). */ + schema: unknown + /** Current resolved value. */ + value: unknown + /** Owner's declared effect timing. */ + applies: SettingsApplies +} + +/** Owner-facing handle for one registered namespace. */ +export interface SettingsScope { + /** Current resolved value: schema defaults, then `base`, then the user layer. */ + get(): T + /** + * Observe committed changes to this namespace's resolved value. + * @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): () => void + /** + * Merge a partial patch into this namespace's user layer and persist it. + * @param patch - plain-object patch over the user section. + */ + update(patch: object): Promise +} + +declare module 'cordis' { + interface Context { + settings: Settings + } + + interface Events { + /** + * 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. + * @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 + } +} + +/** Whether a value is a plain data object (not an array, null, or class instance). */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const proto: unknown = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** + * Layer `over` onto `under`: plain objects merge recursively, every other + * value (arrays included) replaces the lower layer wholesale, and `undefined` + * entries in `over` are ignored so a sparse patch cannot erase lower keys. + */ +function mergeLayers(under: unknown, over: unknown): unknown { + if (over === undefined) return under + if (!isPlainObject(under) || !isPlainObject(over)) return over + const merged: Record = { ...under } + for (const [key, value] of Object.entries(over)) { + if (value === undefined) continue + merged[key] = key in merged ? mergeLayers(merged[key], value) : value + } + return merged +} + +/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */ +function deepFreeze(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value + for (const entry of Object.values(value)) deepFreeze(entry) + return Object.freeze(value) +} + +/** One live namespace registration owned by a registrant fiber. */ +interface SettingsRegistration { + ns: SettingsNamespace + schema: z + base: unknown + applies: SettingsApplies + resolved: unknown + watchers: Set<(next: never, prev: never) => void> +} + +/** + * Abstract settings service. Providers implement raw-document storage + * (`load`/`persist`) and push external changes through {@link Settings.publish}; + * the base class owns namespace registration, resolution, validation, change + * detection, and the `settings/updated` commit event. + */ +export abstract class Settings extends Service { + private readonly registrations = new Map() + /** Latest published raw document; empty until the provider's first publish. */ + private document: Record = {} + + constructor(ctx: Context) { + super(ctx, 'settings') + } + + /** Whether {@link update} may persist through this provider. */ + abstract readonly writable: boolean + + /** + * Read the provider's current raw document (namespace to raw section). + * @returns the detached raw document. + */ + protected abstract load(): Promise> + + /** + * Durably store one namespace's merged user section. + * @param ns - the namespace being written. + * @param section - the complete merged user section to store. + */ + protected abstract persist(ns: SettingsNamespace, section: Record): Promise + + /** + * 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(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope { + if (this.registrations.has(ns)) { + throw new Error(`settings namespace "${ns}" is already registered`) + } + const registration: SettingsRegistration = { + ns, + schema: schema as z, + base: options?.base, + applies: options?.applies ?? 'live', + resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))), + watchers: new Set(), + } + this.ctx.effect(() => { + this.registrations.set(ns, registration) + return () => this.registrations.delete(ns) + }, `settings.register(${JSON.stringify(String(ns))})`) + return { + get: () => registration.resolved as T, + watch: (callback) => { + registration.watchers.add(callback) + return () => registration.watchers.delete(callback) + }, + update: patch => this.update(ns, patch), + } + } + + /** + * Describe every registered namespace for configuration surfaces. + * @returns one descriptor per registered namespace, in registration order. + */ + describe(): SettingsDescriptor[] { + return [...this.registrations.values()].map(registration => ({ + ns: registration.ns, + schema: registration.schema.toJSON(), + value: registration.resolved, + applies: registration.applies, + })) + } + + /** + * 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 { + return this.registrations.get(ns)?.resolved + } + + /** + * 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. + * @param ns - the registered namespace to update. + * @param patch - plain-object patch over the user section. + */ + async update(ns: SettingsNamespace, patch: object): Promise { + const registration = this.registrations.get(ns) + if (registration === undefined) { + throw new Error(`settings namespace "${ns}" is not registered`) + } + if (!this.writable) { + throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`) + } + if (!isPlainObject(patch)) { + throw new TypeError(`settings update for "${ns}" must be a plain object patch`) + } + const section = mergeLayers(this.section(ns) ?? {}, patch) as Record + const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) + await this.persist(ns, section) + this.document[ns] = section + this.commit(registration, next, 'update') + } + + /** + * Provider hook: commit a complete raw document observed in storage. Each + * registered namespace re-resolves; an invalid section keeps that + * namespace's last good value and warns, other namespaces still commit. + * @param doc - the detached raw document (unregistered sections preserved). + * @param source - change origin; defaults to `provider`. + */ + protected publish(doc: Record, source: SettingsUpdateSource = 'provider'): void { + this.document = doc + for (const registration of this.registrations.values()) { + let next: unknown + try { + next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns))) + } catch (error) { + this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) + this.ctx.logger.warn(error) + continue + } + this.commit(registration, next, source) + } + } + + /** Read one namespace's raw user section, rejecting non-object sections. */ + private section(ns: SettingsNamespace): Record | undefined { + const section = this.document[ns] + if (section === undefined) return undefined + if (!isPlainObject(section)) { + throw new TypeError(`settings section "${ns}" must be an object of keys`) + } + return section + } + + /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ + private resolve(schema: z, base: unknown, section: Record | undefined): T { + // The merged candidate is untyped by construction; the schema call is the + // runtime validation that admits it into T. + return schema(mergeLayers(base, section) as never) + } + + /** Commit a resolved value when changed: swap, notify watchers, emit the event. */ + private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void { + const prev = registration.resolved + if (deepEqual(next, prev)) return + registration.resolved = next + for (const watcher of [...registration.watchers]) { + try { + watcher(next as never, prev as never) + } catch (error) { + this.ctx.logger.warn('settings: watcher for "%s" failed', registration.ns) + this.ctx.logger.warn(error) + } + } + this.ctx.emit('settings/updated', registration.ns, next, prev, source) + } +} + +export default Settings diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts new file mode 100644 index 0000000000..235f413aae --- /dev/null +++ b/packages/settings/settings/src/invariant.ts @@ -0,0 +1,41 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-settings`. + * @module @deepseek-ai/dsh-settings/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-settings' + +/** Cordis companion plugin name. */ +export const name = 'settings-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the commit-event contract: `settings/updated` fires only for a + * currently registered namespace and only when the resolved value changed. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('settings/updated', (ns, next, prev) => { + const settings = ctx.get('settings') + if (settings === undefined) { + fail(`settings/updated for "${ns}" emitted without a live settings service`) + } + if (settings.get(ns) === undefined) { + fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`) + } + if (JSON.stringify(next) === JSON.stringify(prev)) { + fail(`settings/updated for "${ns}" emitted without a resolved-value change`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts new file mode 100644 index 0000000000..f12ff6126c --- /dev/null +++ b/packages/settings/settings/tests/invariant.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SettingsInvariant from '../src/invariant.ts' +import { settingsNamespace } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +async function setup(withProvider: boolean): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(SettingsInvariant) + if (withProvider) await ctx.plugin(MemorySettings) + return ctx +} + +describe('settings invariants', () => { + it('fails a settings/updated emission without a live settings service', async () => { + const ctx = await setup(false) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider') + }).toThrow(/without a live settings service/) + }) + + it('fails a settings/updated emission for an unregistered namespace', async () => { + const ctx = await setup(true) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider') + }).toThrow(/unregistered/) + }) + + it('fails a settings/updated emission without a resolved-value change', async () => { + const ctx = await setup(true) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + theme: z.string().default('dark'), + })) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update') + }).toThrow(/without a resolved-value change/) + }) +}) diff --git a/packages/settings/settings/tests/memory.ts b/packages/settings/settings/tests/memory.ts new file mode 100644 index 0000000000..0b20771cbe --- /dev/null +++ b/packages/settings/settings/tests/memory.ts @@ -0,0 +1,53 @@ +/** + * In-memory settings provider fixture: the smallest real subclass of the seam, + * used by the base-class behavior suite in place of a file- or network-backed + * provider. Kept in `tests/` because production providers live in their own + * packages. + */ + +import { Service } from 'cordis' +import { Settings, type SettingsNamespace } from '../src/index.ts' + +/** In-memory provider exposing the protected seam hooks to tests. */ +export class MemorySettings extends Settings { + /** Raw document the provider "storage" currently holds. */ + doc: Record + /** Every persist() call observed, in order. */ + persisted: Array<{ ns: SettingsNamespace; section: Record }> = [] + /** When false, update() must reject before reaching persist(). */ + writableFlag: boolean + + constructor(ctx: ConstructorParameters[0], options?: { + doc?: Record + writable?: boolean + }) { + super(ctx) + this.doc = structuredClone(options?.doc ?? {}) + this.writableFlag = options?.writable ?? true + } + + get writable(): boolean { + return this.writableFlag + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.persisted.push({ ns, section: structuredClone(section) }) + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } + + /** Simulate an external storage change reaching the provider. */ + pushExternal(doc: Record): void { + this.doc = structuredClone(doc) + this.publish(structuredClone(doc)) + } + + async* [Service.init](): AsyncGenerator<() => void, void, void> { + this.publish(await this.load()) + yield () => { this.persisted.length = 0 } + } +} diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts new file mode 100644 index 0000000000..a3d223c3f7 --- /dev/null +++ b/packages/settings/settings/tests/settings.spec.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +interface NestedConfig { + retry: { attempts: number; delayMs: number } + tags: string[] +} + +const NestedSchema: z = z.object({ + retry: z.object({ + attempts: z.number().default(2), + delayMs: z.number().default(100), + }), + tags: z.array(z.string()).default(['default']), +}) + +async function boot(options?: ConstructorParameters[1]) { + const ctx = new Context() + await ctx.plugin(MemorySettings, options) + const provider = ctx.get('settings') as MemorySettings + return { ctx, provider } +} + +/** Record every settings/updated emission. */ +function recordUpdates(ctx: Context) { + const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = [] + ctx.on('settings/updated', (ns, next, prev, source) => { + events.push({ ns, next, prev, source }) + }) + return events +} + +describe('settingsNamespace', () => { + it('brands lowercase kebab-case names', () => { + expect(settingsNamespace('ui-theme')).toBe('ui-theme') + }) + + it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => { + expect(() => settingsNamespace(value)).toThrow(TypeError) + }) +}) + +describe('registration', () => { + it('resolves schema defaults, then composition base, then the user layer', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + // theme: user layer wins; fontSize: base wins over the schema default. + expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) + }) + + it('rejects a duplicate namespace loud', async () => { + const { ctx } = await boot() + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)) + .toThrow(/already registered/) + }) + + it('fails registration when the stored section is invalid for the schema', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow() + }) + + it('fails registration when the stored section is not an object', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)) + .toThrow(/must be an object/) + }) + + it('describes registered namespaces with schema JSON, value, and applies', async () => { + const { ctx } = await boot() + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' }) + const descriptors = ctx.settings.describe() + expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([ + ['ui-theme', 'live'], + ['workspace', 'restart'], + ]) + expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 }) + // schemastery's canonical wire form: a { uid, refs } envelope whose root ref + // is the object schema — the shape schema-driven form UIs reconstruct from. + const serialized = descriptors[0]!.schema as { uid: number; refs: Record } + expect(serialized.refs[String(serialized.uid)]?.type).toBe('object') + }) + + it('reads undefined for an unregistered namespace', async () => { + const { ctx } = await boot() + expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined() + }) + + it('hands out frozen resolved values', async () => { + const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } }) + const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + const value = scope.get() + expect(Object.isFrozen(value)).toBe(true) + expect(Object.isFrozen(value.retry)).toBe(true) + expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError) + }) + + it('removes the namespace and its observers when the registrant fiber disposes', async () => { + const { ctx, provider } = await boot() + const seen: unknown[] = [] + let scope: SettingsScope | undefined + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(next => seen.push(next)) + }, + }) + await fiber + expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 }) + + await fiber.dispose() + expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined() + expect(ctx.settings.describe()).toEqual([]) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(seen).toEqual([]) + + // The namespace is free again, and re-registration resolves the user layer + // that kept living in storage while nobody owned the namespace. + const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(again.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) +}) + +describe('update', () => { + it('persists the merged user section without baking in the base layer', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + await scope.update({ theme: 'dark' }) + expect(provider.persisted).toEqual([ + { ns: 'ui-theme', section: { theme: 'dark' } }, + ]) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) + }) + + it('deep-merges nested objects and replaces arrays wholesale', async () => { + const { ctx, provider } = await boot({ + doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } }, + }) + const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + await scope.update({ retry: { attempts: 7 }, tags: ['c'] }) + expect(provider.persisted[0]!.section).toEqual({ + retry: { attempts: 7, delayMs: 300 }, + tags: ['c'], + }) + expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] }) + }) + + it('commits, notifies watchers, and emits with source update', async () => { + const { ctx } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + await scope.update({ theme: 'light' }) + expect(watcher).toHaveBeenCalledWith( + { theme: 'light', fontSize: 14 }, + { theme: 'dark', fontSize: 14 }, + ) + expect(events).toEqual([{ + ns: 'ui-theme', + next: { theme: 'light', fontSize: 14 }, + prev: { theme: 'dark', fontSize: 14 }, + source: 'update', + }]) + }) + + it('rejects an invalid patch before persisting anything', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ fontSize: 'big' })).rejects.toThrow() + expect(provider.persisted).toEqual([]) + expect(events).toEqual([]) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: undefined, fontSize: 18 }) + expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 }) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 }) + }) + + it('rejects a non-object patch', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update([1])).rejects.toThrow(TypeError) + await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError) + }) + + it('accepts a null-prototype patch object', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number } + patch.fontSize = 18 + await scope.update(patch) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) + + it('rejects an unregistered namespace', async () => { + const { ctx } = await boot() + await expect(ctx.settings.update(settingsNamespace('missing'), {})) + .rejects.toThrow(/not registered/) + }) + + it('rejects on a read-only provider before reaching persist', async () => { + const { ctx, provider } = await boot({ writable: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/) + expect(provider.persisted).toEqual([]) + }) +}) + +describe('publish', () => { + it('notifies watchers of an external change with source provider', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).toHaveBeenCalledWith( + { theme: 'light', fontSize: 14 }, + { theme: 'dark', fontSize: 14 }, + ) + expect(events[0]!.source).toBe('provider') + }) + + it('stays silent when the resolved value is deep-equal', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).not.toHaveBeenCalled() + expect(events).toEqual([]) + }) + + it('keeps the last good value for an invalid section while other namespaces commit', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + provider.pushExternal({ + 'ui-theme': { fontSize: 'broken' }, + workspace: { retry: { attempts: 9 } }, + }) + expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 }) + expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] }) + expect(events.map(event => event.ns)).toEqual(['workspace']) + }) + + it('recovers from a bad section once storage turns valid again', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + provider.pushExternal({ 'ui-theme': { fontSize: 18 } }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) +}) + +describe('watch', () => { + it('stops after its disposer runs', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + const dispose = scope.watch(watcher) + dispose() + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).not.toHaveBeenCalled() + }) + + it('contains a throwing watcher without blocking the commit or other watchers', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(() => { throw new Error('watcher boom') }) + const second = vi.fn() + scope.watch(second) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(second).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(1) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) +}) diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json new file mode 100644 index 0000000000..dd18d27fc3 --- /dev/null +++ b/packages/settings/settings/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9538051cd..fce89446a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3636,6 +3636,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/settings/settings: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + + packages/settings/settings-local: + dependencies: + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../settings + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/skill/skill: dependencies: schemastery: diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 4cae854147..a27d6af22b 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1705, + "AGENTS.md": 1710, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 835 + "packages/README.md": 845 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b10ad9dfac..b7df3dc622 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -217,6 +217,12 @@ const FOUNDATION_TYPE_NAMES = new Set([ /** Project types deliberately documented outside the core-data catalog. */ const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md', + SettingsNamespace: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsUpdateSource: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsRegisterOptions: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsScope: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsDescriptor: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)', BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1d4a7e447d..a077042d3c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -137,6 +137,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'settings', + pkg: 'settings', + title: 'User-settings seam', + mode: 'seam', + implementations: ['settings-local'], + consumers: [], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.', + }, { key: 'telemetry', pkg: 'session-telemetry', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 13dc2def8d..dc02127a17 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -90,6 +90,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, + 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' }, + 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index d4beec7afa..8f39d3ec5f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -75,6 +75,7 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/settings/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -156,6 +157,7 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/settings/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", "./packages/storage/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 9cf2a86bda..2c9bc2a652 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -58,6 +58,8 @@ { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/settings/settings" }, + { "path": "./packages/settings/settings-local" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" },