Merge origin/master into fix-webplugins-watch-flake

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
#	.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
#	packages/host/webserver/tests/web-plugins.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-25 13:23:42 +08:00
141 changed files with 8995 additions and 2562 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-23-client-plugin-loading-model.md: c8869f068bf6d715345d56145907568c8310499e
2026-07-23-client-plugin-loading-model.zh.md: 1ceaba1b36e32a5db7c0a51e6d9f113eb9dd8339
2026-07-23-client-plugin-loading-model.md: 3513e026785fc366455bb32bf788a3a098275bb1
2026-07-23-client-plugin-loading-model.zh.md: f31a1b076a5d93db44c67463730b38283d44ff7f

View File

@@ -56,11 +56,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
**Host side — compose the graph.**
1. The composing app (`apps/cli`) mounts the roster as in-memory Loader entries via `mountWebPlugins`. The roster is one flat list of the plugin packages, plus the `client-hmr` row under `--dev`. A roster package that fails to import throws loud at mount.
2. The registry (`createHostWebPluginRegistry`) scans the mounted entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — load-time fail loud.
3. The registry rescans on cordis `internal/plugin`, microtask-debounced; a rescan failure keeps serving the previous graph. Each bundle's content is hashed into its `rev` (cache busting + HMR diff anchor), and the row set into `graph.rev`. Every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are a wire contract dual-held on both sides, because the webserver keeps zero workspace dependencies.
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
Why is the roster a hand-written list and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call. The roster lives in `apps/cli/web.ts` rather than cordis.yml only because `dsh web`'s host is a hand-assembled `bootHost` with no Loader config tree yet.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch is fetch + execute, which registers factories only. A single row's prefetch failure is swallowed here: phase two's import retries the fetch and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
@@ -74,9 +74,9 @@ Why is the roster a hand-written list and not a scan? Because which plugins comp
### Hot reload: one driver plugin, self-watched bundles
Whether hot reload is active is a composition decision: dev graphs include the `client-hmr` row (a normal plugin package) and turn on bundle watching; prod graphs do neither.
Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
How does a rebuilt bundle become a reload signal? The webserver observes it itself — no builder tells it. The registry scan already holds every plugin's bundle path (`clientPath`), so in dev mode one registry-owned interval stat-polls every scanned bundle file against an explicit baseline the registry captures synchronously before construction returns (not `fs.watchFile`, whose asynchronous first-stat baseline silently absorbs a rebuild landing during registry construction — a CI-reproduced miss). Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change the registry re-hashes that row (`rebuilt(id)`); when the `rev` actually changed, it broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Rescans stage table, graph, and watch baselines atomically (a failed rescan keeps all three previous values), and a bundle missing at poll time marks its watch dirty so the reappearing file re-hashes even with identical metadata; dispose clears the one timer. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
On the browser side, the driver reloads one plugin per frame, serialized:
@@ -116,7 +116,7 @@ One governance implementation runs on both sides of the wire; the browser-specif
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land.
Roster endgame: when `dsh web` moves to config-tree boot, the roster lands in cordis.yml — client plugin packages become ordinary config-tree entry rows, `mountWebPlugins` and the `CLIENT_PACKAGES` constant disappear, and recomposing a deployment means swapping the yml/overlay. The registry needs zero changes for that move, since its `internal/plugin` subscription already discovers whatever entries the tree mounts.
Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
## Alternatives considered

View File

@@ -56,11 +56,11 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
**host 侧——组合这张图。**
1. 负责组合的 app`apps/cli``mountWebPlugins` 把名册挂载为内存中的 Loader entry。名册是插件包的一张平铺清单`--dev` 下外加 `client-hmr`。名册 import 失败的包在挂载时大声抛错
2. 注册表(`createHostWebPluginRegistry`)扫描已挂载 entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——装载期大声失败。
3. 注册表在 cordis `internal/plugin` 上重扫,微任务去抖;重扫失败则继续供给上一张图。每个 bundle 的内容哈希`rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型是两侧各持一份的 wire 契约,因为 webserver 保持零 workspace 依赖
1. 负责组合的 app`apps/cli`把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册 import 失败由 boot 的 `assertEntriesLoaded` 捕获
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败FAILED fiber由 sweep 上报)
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries包元数据含「非 client 包」的否定结论按名永久缓存bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush初扫与稳态共享一条实现。每个 bundle 的内容哈希`rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知它是朴素路由注册插件bundle 路由和 index 渲染 tap 都由 modules 自己注册)
为什么名册是手写清单而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定。名册住在 `apps/cli/web.ts` 而非 cordis.yml只是因为 `dsh web` 的 host 还是一个手工装配的 `bootHost`,没有 Loader 配置树
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定node 半只扫描配置树实际挂载了的东西
**第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即 fetch + 执行,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试 fetch 并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障不是身份。包声明它注册表把它带进图行。基础设施插件connection、runtime、ui-theme、i18n外加 hmr声明它UI 插件则径直按需到达。
@@ -74,9 +74,9 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
### 热重载:一个驱动插件,自行监视的 bundle
热重载是否启用是一项组合决策dev 图包含 `client-hmr` 行(一个常规的插件包)并开启 bundle 监视prod 图两者皆无。
热重载是否启用是一项组合决策dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE 通道prod 组合不挂载,两者皆无。
重建好的 bundle 怎么变成重载信号?webserver 自己观察——没有构建器来通知它。注册表扫描本就握有每个插件的 bundle 路径(`clientPath`),因此 dev 模式下由注册表自持的单个定时器对每个已扫描的 bundle 文件做 stat 轮询,比对基线由注册表在构造返回之前同步捕获(不用 `fs.watchFile`:它以异步首次 stat 建立基线,会把注册表构造期间落盘的重建静默吸收进基线——CI 上复现过的漏报。轮询是刻意选择inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,注册表就重哈希该行(`rebuilt(id)`;当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSEServer-Sent Events通道连接即发全量图变更时发 `rebuilt` 帧,仅供呈现的 wire永不进会话日志。重扫对表、图、监视基线三者原子换入(重扫失败则三者都保持旧值);轮询时 bundle 缺失会给该监视打上 dirty 标记文件重现时即使元数据相同也强制重哈希dispose资源释放只需清掉那一个定时器。轮询间隔是一个经校验的配置字段(默认 500ms不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSEServer-Sent Events通道连接即发全量图变更时发 `rebuilt` 帧,仅供呈现的 wire永不进会话日志。轮询是刻意选择inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500msdispose资源释放会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
浏览器侧,驱动插件每帧重载一个插件,串行执行:
@@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模
接受的代价vendored Loader 在浏览器里背着闲置机件EntryTree 持久化是 no-op分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。
名册的终局:当 `dsh web` 迁到配置树 boot名册落进 cordis.yml——client 插件包变成普通的配置树 entry 行`mountWebPlugins``CLIENT_PACKAGES` 常量消失,重组一次部署等于换 yml/overlay。注册表为这次迁移零改动,因为它的 `internal/plugin` 订阅本就发现配置树挂载的任何 entry
名册的终局2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/cordis.yml``mountWebPlugins``CLIENT_PACKAGES` 常量消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达传输拆分同轮落地webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半
## Alternatives considered

View File

@@ -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
2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa

View File

@@ -0,0 +1,42 @@
# Agent Note: dsh web config-tree boot and the web transport layering
Status: implemented
English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
> Scope: how `dsh web` composes (cordis.yml + pre-cordis boot classes + config sources) and how the web transport splits across packages (gateway / carrier / binding / graph / dev-reload). The [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the browser-side loading chain this composition feeds.
## Problem
`dsh web` was the only hand-assembled surface left: `bootHost` mounted 32 plugins with configs pinned in code (violating no-hardcoded-tunables), the client roster was a `web.ts` constant, and TUI/headless had long been yml compositions. The transport layer misplaced responsibilities to match: the webserver self-described as a dumb carrier yet knew the `__DSH_BOOT__` graph, owned the SSE channel, and hard-coded the `/api/*` prefix; the dev bundle watch lived inside the prod registry behind a `watch?` flag with no lifecycle owner; the graph registry rescanned everything on every `internal/plugin` emission; per-request errors and fatal server errors shared one sink that always exited the process. One user-visible defect rode along: the web path never loaded `$DSH_HOME/.env`, so `DSH_HOME=… dsh web` could not find an API key living there.
## Decision
**Composition is one flat config tree.** `apps/cli/cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the ten `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 1025% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
## Consequences
- Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger.
- A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| Dedicated `dsh-host-profile` receiver package | The profile json is consumed at patch time; the only runtime consumer of `{provider, model}` is the gateway itself — its config is the receiver |
| Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls |
| Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too |
| A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half |
| dev overlay / `cordis.dev.yml` | One yml; `!!js` cannot conditionalize row existence, and `--dev` appending one row is the entire difference |
| env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
| Unbarriered create-after-prefetch (`arrive()` dedup as safety) | Disproved by a 1025% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
| json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge |

View File

@@ -0,0 +1,42 @@
# Agent Notedsh web 的 config-tree boot 与 web 传输分层
Status: implemented
[English](2026-07-24-web-config-tree-boot-and-transport-layering.md) | 中文
> 范围:`dsh web` 如何组合cordis.yml + cordis 之前的 boot 类 + 配置源),以及 web 传输如何跨包分层(网关 / 载体 / 绑定 / 图 / 开发期重载)。浏览器侧装载链归 [client 插件装载 note](2026-07-23-client-plugin-loading-model.md) 所有,本组合只是它的供给方。
## 问题
`dsh web` 曾是仅剩的手工装配面:`bootHost` 逐个挂 32 个插件、config 钉死在代码里(违反 no-hardcoded-tunablesclient roster 是 `web.ts` 常量,而 TUI/headless 早已是 yml 组合。传输层的职责错位与之配套webserver 自称哑载体却认识 `__DSH_BOOT__` 图、拥有 SSE 通道、硬编码 `/api/*` 前缀dev 的 bundle watch 寄居在 prod registry 里靠 `watch?` 参数开关、生命周期无主;图 registry 对每次 `internal/plugin` 全量重扫;单请求失败与致命 server 错误共用一个一律退进程的 sink。还有一个用户可见缺陷web 路径不装 `$DSH_HOME/.env``DSH_HOME=… dsh web` 读不到自定义 home 下的 API key。
## 决策
**组合是一棵平铺 config tree。** `apps/cli/cordis.yml` 持有全部行——host runtime32 行)、`api-gateway` 行、`webserver` 行、十个 `dshClient` 行(浏览器 rostermodules 行同时是 host 行)。不做 spine bundle每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义激活由服务可用性驱动boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`import 失败)、`installFailLoud`(迟到的 apply 拒绝、all-ACTIVE sweepPENDING fiber——cordis inject 等待没有超时)。
**boot 胶水是一对 class。** `AppCLIEntry`apps/cli`AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 envambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`双视角npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require不受 fiber inject 等待保护i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 1025% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
**每个配置源有唯一声明位置。** yml 静态值是工程默认profile json`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model``api-gateway` 行,`persistenceRoot` → jsonl 行CLI flags 映射到 `webserver` 行、字段集与 json 不相交env 值经 yml `!!js` 表达式进入绝不进映射表。patch 整体替换行 config故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`config `{provider, model}`provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入依赖方向允许runtime 保留 `bootHost`/`startHost` 供 headless`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer``register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输网关零改动。modules node 半(`ClientModuleHostService`provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏flush 逐名对账 live entries包元数据含否定结论永久缓存重哈希唯一入口 `rebuilt(id)`、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged``/plugins/events` SSE 路由。
**包出口纪律。** modules 包只暴露 `.`node 半)与 `./client`(完整浏览器半:`ClientModuleSystem``parseBootManifest`、收编插件面——不设特设子路径wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__``./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`
## 后果
- 重组一个 web 部署 = 改 yml/patch退役件`mountWebPlugins``CLIENT_PACKAGES``createHostWebPluginRegistry``startWebServer`、webserver 的图/SSE/api 知识)全部删除。
- headless 本轮仍走 `bootHost`它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。
- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration无声打散全程序的 `Context` merge`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
## Alternatives considered
| 弃案 | 一行理由 |
|---|---|
| 专门的 `dsh-host-profile` 受体包 | profile json 在 patch 阶段消费完;`{provider, model}` 的唯一运行时消费方是网关自己——受体即网关 config |
| runtime 里的 `assembly` 垫层插件provide `apiHandler` | 它的存在只因 `createApiProxy` 住 runtime本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 |
| 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 |
| modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 |
| dev overlay / `cordis.dev.yml` | 一套 yml`!!js` 无法条件化行存在性,`--dev` 追加一行就是全部差异 |
| env 进映射表 | 同一字段将出现 env/json 双源,需再发明优先级 |
| create 不等预取(以 `arrive()` 去重为安全依据) | 被 1025% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 |
| json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,写入方要懂 cordis |

View File

@@ -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
2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f
2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91

View File

@@ -0,0 +1,329 @@
# Agent Note: Domain KV storage capability seam and the workspace entity
Status: proposed
English | [中文](2026-07-24-domain-kv-storage-and-workspace.zh.md)
## Problem
The host's only persistence surface is the session event log (`packages/session-persistence`: append-only, one file per session). Anything that does not belong to a single session has nowhere to live, and two real needs exist today:
- **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned).
- **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates.
Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code.
## Proposal
Create the `packages/storage/` group — the `ctx.storage` hub (backend registry + data-form mounts), two backends, the domain data form — plus the workspace consumer package; extend `SessionPersistence` with a delete primitive.
| Package | Path | ctx surface | This phase |
| --- | --- | --- | --- |
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage` (the hub) | ✓ |
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | registers backend `json` | ✓ |
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | registers backend `sqlite` | ✓ |
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | mounts `ctx.storage.domain` | ✓ |
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
| `SessionPersistence.delete` extension + cascade orchestration | `packages/session-persistence/*` | new method on the existing seam | ✗ future work (session side untouched this phase) |
| `workspace.*` / `session.delete` RPC, GUI wiring, boot assembly | — | — | ✗ next phase |
(workspace lives in its own group rather than `packages/host/`: the host group's naming rule requires the `dsh-host-*` prefix while this package is named `dsh-workspace`; and the workspace entity is a domain concept, not bound to the host assembly tier. Unrelated to the existing `workspace-context` package — that is an AGENTS.md instruction loader.)
Dependency direction: `dsh-workspace``dsh-domain``dsh-storage` ← the two backends. `dsh-workspace` additionally depends on the read-only face of `ctx.sessionPersistence` (attach's cwd check reads the session header; when the service is absent, attach rejects outright — no verification, no bookkeeping). The `ctx.sessions` running-check for session deletion moves into future work together with the cascade.
### `dsh-storage`: the storage hub
A pure registration hub, no IO of its own, no Config. The `Storage` service mounts at `ctx.storage` with two faces: `backend` (a `BackendRegistry`: `register(name, backend)` returns the disposer, duplicate names throw; `get(name)` throws `backend-not-found` for unknown names) and data-form mounting (`mount(form, facility)` over the merge-extensible `StorageForms` map, into which `dsh-domain` merges the `domain` key; unmounted access throws `form-not-mounted`). The signature text lives in `packages/storage/storage/src/index.ts` and `src/registry.ts`.
**Multiple backends stay mounted side by side**; which backend serves a domain is `dsh-domain`'s configuration (below), never a global either-or. Disposer semantics = remove the name from the table; closing the backend itself belongs to the backend package's effect closure, unregister first then close.
A backend is one **medium owner** (a file-tree root / one db file) exposing primitives through **data-shape facets** — only `kv` this phase; the session migration adds `log` (see the migration section). A facet is an optional member: absence means the backend cannot serve that shape, and resolution fails loud. The `kv` facet's primitive surface: `open(descriptor)` (descriptor = name/version/table list/global flag, with names and table names restricted to `^[a-z][a-z0-9_]*$` doubling as file-name and SQL-identifier segments) returns a unit exposing `loadAll` / `putRecord` / `deleteRecord` (missing key is a no-op) / `setGlobal` / `close` (idempotent); values are opaque JSON to the backend. The normative text (with per-method JSDoc) is `packages/storage/storage/src/backend.ts`.
The backend contract (asserted clause by clause by the shared conformance suite, one suite for both backends):
1. `open` creates when the medium holds nothing (lazy materialization allowed: may defer to the first write, but `loadAll` must immediately serve empty tables); loads when the medium exists.
2. A stored version ≠ descriptor.version → `StorageError('version-mismatch')`; no migration, no rebuild.
3. Durability: after a write primitive resolves, a process crash followed by a re-open must observe the write in `loadAll`.
4. The backend does not promise write ordering within a unit — **the caller serializes**; the backend only guarantees each single call is atomic (JSON whole-file replace / SQLite single statement).
5. `deleteRecord` is idempotent; `putRecord` overwrites.
6. Any string key / any JSON value is safe (keys never reach file paths, a structural property).
7. `close` is idempotent; any operation after close → `StorageError('closed')`.
The error vocabulary is `StorageError` with a code discriminant: `backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed` (`packages/storage/storage/src/error.ts`).
### `dsh-storage-json`
Config is `root` only (required, no default, schemastery); apply registers backend `json` inside `ctx.effect()`, and the disposer unregisters the name before `backend.close()`.
- Layout `<root>/<unitName>.json`, one file per unit; directory 0o700, files 0o600.
- File format (version stamp in the header; the file is always the current net state, `JSON.stringify(…, null, 2)` human-readable — that legibility is this backend's reason to exist):
```json
{
"unit": { "name": "workspace", "version": 1 },
"global": null,
"tables": { "workspaces": { "<key>": {} } }
}
```
- Writes: every write primitive = full serialization of the in-memory state → temp write + fsync → atomic rename publish (the Windows variant follows session-persistence-jsonl's win32 path). Memory is authoritative, disk is its projection.
- `loadAll`: parse the whole file at open; a missing `unit` header, non-object tables, etc. → `malformed-medium`. A missing file = an empty unit, materialized on first write.
### `dsh-storage-sqlite`
Config is `path` (required, `':memory:'` allowed) plus `journalMode` (enum, default `wal`); apply mirrors json, registering backend `sqlite`.
- `node:sqlite` `DatabaseSync`; the open sequence follows session-persistence-sqlite: mkdir 0o700 → `open(path,'wx',0o600)` exclusive create when missing → `PRAGMA foreign_keys=ON` → journal_mode → version check → create tables.
- Physical layout version `STORAGE_SQLITE_SCHEMA_VERSION = 1` in `PRAGMA user_version`: 0 → stamp; ≠ → `version-mismatch`.
- DDL (all STRICT; table names concatenated from the restricted character set with the `u_` prefix, no external input ever reaches DDL):
```sql
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
CREATE TABLE IF NOT EXISTS unit_globals (
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
-- 每 unit 每表:
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
```
- Unit versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
- Write primitives are single statements and thus atomic; no cross-statement transactions needed (the domain layer has no cross-table transactions, see the out-of-scope list).
### `dsh-domain`: the domain data form
A single implementation, not abstracted; consumers depend on this layer only and never touch backends directly.
```ts ignore-check
export const Config = z.object({
backend: z.string().required(), // 默认后端名,必填
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
})
export function apply(ctx: Context, config: Config) {
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
}
```
(Facility unmount order: dispose each domain first (drain its write chain), then remove the name from the hub — in-flight writes still emit `domain/changed` during the drain, and the event-consistency invariant resolves domains back through the facility, so the name must stay resolvable at that point.)
Domain declarations (the spec object is defined and exported by the package that owns the domain — the single source of type and runtime truth; schemas use zod with `z.infer` deriving the types without re-declaration — the record model projects into RPC wire schemas next phase and the wire boundary is all zod; schemastery still owns plugin Config only):
```ts ignore-check
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
export interface DomainSpec {
readonly name: string // ^[a-z][a-z0-9_]*$
readonly version: number
readonly global?: DomainGlobalSpec<unknown>
readonly tables: Record<string, DomainTableSpec<string, unknown>>
}
export function defineDomain<S extends DomainSpec>(spec: S): S
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
```
`DomainFacility.open(spec)` exact semantics (sequential; any failing step fails the whole open):
1. A domain with this name already open → `DomainError('already-open')`.
2. Backend name = `config.routes[spec.name] ?? config.backend`; `ctx.storage.backend.get(name)` (an unmounted name propagates `backend-not-found` — misconfiguration fails loud).
3. Backend lacks the `kv` facet → `DomainError('facet-unsupported')`.
4. `kv.open(descriptorOf(spec))` (the descriptor is a direct projection of the spec).
5. `loadAll()`; every record passes `valueSchema.parse`, the global passes its schema (null takes `initial`, not persisted — first write materializes). A failure → `DomainError('invalid-record', { table, key })` (the durable boundary must validate; the write side does not re-validate).
6. Construct the `Domain` and register `ctx.effect()`: the disposer drains the write chain → `unit.close()`.
```ts ignore-check
export interface Domain</* 由 spec 推导 */> {
readonly name: string
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
}
export interface KvTable<K extends string, V> {
get(key: K): V | undefined // 内存快照,同步
entries(): IterableIterator<[K, V]>
keys(): IterableIterator<K>
readonly size: number
put(key: K, value: V): Promise<void>
delete(key: K): Promise<boolean> // false = 本就不存在
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
}
```
Rules:
- **Single-level mapping**: key → record, no nested tables; hierarchical needs use composite keys or fields inside the value. The two backends stay isomorphic as a result (one JSON object level ↔ one SQLite row).
- **Records are plain data**: immutable, directly JSON-serializable POJOs; values returned by `get`/`entries` must not be mutated in place (TypeScript readonly projection, no runtime freezing). Behavior-carrying domain objects belong to consumer packages.
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
- **Version fails loud**: a stored version differing from the spec throws outright; no migration, no rebuild (the data is not regenerable; pre-release rejects old formats).
- **Change events**: after each write's durability resolves, emit `domain/changed` (`@mode emit`), one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); the payload `DomainChanged` is a put/deleted discriminated union — domain + table + key (both `''` for global changes) + operation, with the put branch carrying the new snapshot value and the deleted branch carrying none (`packages/storage/storage-domain/src/events.ts`). This is next phase's RPC push-frame event source. The error vocabulary is `DomainError`, codes: `already-open` / `facet-unsupported` / `invalid-record` (with `{ table, key }`) / `missing-key` / `closed`.
### Future work: session-side deletion (design settled, not implemented this phase)
This section is the settled construction spec; the implementation phase changes code only, not semantics. No session-persistence file is modified this phase.
```ts ignore-check
export abstract class SessionPersistence extends Service {
/**
* Permanently delete one session's stored log.
* Queued on the per-id write chain (serialized with in-flight appends).
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
* After deletion the id behaves as unknown for every subsequent operation.
*/
abstract delete(id: SessionId): Promise<void>
}
```
- JSONL backend: unlink the session's file (including the `.zstd` variant); neither file nor intent → reject.
- SQLite backend: one transaction `DELETE FROM events…; DELETE FROM sessions…`; zero rows hit and no intent → reject.
- After a successful delete, emit `'session-persistence/deleted'(id: SessionId)` (`@mode emit`; the session-persistence event surface, unrelated to `domain/changed`). Derived data (the session-query full-text index and the like) subscribes and cleans itself; the persistence layer never reaches into indexes, and the crash window is covered by derived indexes being droppable-and-rebuildable.
Orchestration rules (implemented together with the cascade; the `session.delete` RPC and the workspace cascade reuse the same rules):
| Check (in order) | On failure |
| --- | --- |
| No target (the whole subtree when recursive) is running in `ctx.sessions` | throw, delete nothing; callers cancel first then delete — the persistence layer never reaches back into the runtime |
| Non-recursive: the target has no descendants (descendants = the `parentSessionId` transitive closure, derived from `list()` headers) | throw: by default only leaves are deletable; `recursive: true` opts into recursion |
| Recursive order is bottom-up (leaves → root) | — a mid-way crash leaves only "half the subtree deleted, ancestors intact"; re-running the same delete converges, and no dangling parent exists at any moment |
| Some id in the cascade is already gone from disk | skip (idempotent resumption); any other error aborts |
### `dsh-workspace`
The package owns the `WorkspaceId` brand and exposes `ctx.workspace`. The record key is a generated uuid — path is not the key: normalization rewrites it, and reference anchors must be stable.
```ts ignore-check
export type WorkspaceId = Branded<'WorkspaceId'>
export function WorkspaceId(id: string): WorkspaceId
const workspaceRecord = z.object({
path: z.string(), // realpath见下
title: z.string(),
sessionIds: z.array(z.string().transform(SessionId)),
createdAt: z.string(), // ISO
updatedAt: z.string(),
})
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
export const workspaceDomainSpec = defineDomain({
name: 'workspace', version: 1,
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
})
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
export interface Workspace {
readonly id: WorkspaceId
readonly path: string
readonly title: string
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
setTitle(title: string): Promise<void>
/** Record a session under this workspace (idempotent). Rejects when the session
* header's cwd (realpath) differs from this workspace's path. */
attachSession(sessionId: SessionId): Promise<void>
detachSession(sessionId: SessionId): Promise<void>
/** Live directory check, uncached. */
status(): Promise<'ok' | 'missing-dir'>
}
export class WorkspaceRegistry extends Service {
constructor(ctx: Context) // super(ctx, 'workspace')
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
get(id: WorkspaceId): Workspace | undefined
list(): Workspace[]
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
// deletefuture work与 session 级联删一起做,见下);本期不提供任何删除入口
}
```
- **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side.
- **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace.
- Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas.
- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record.
Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline):
| Situation | Behavior |
| --- | --- |
| A ledger id has no session on disk | filtered at `list()`/entity projection; pruned by the next mutate; no error (a normal product of deletion crash-consistency) |
| A session's cwd matches a workspace but is not in the ledger | not owned: no merging, no adoption. The GUI may later build an "orphan sessions" area (orphans = the complement of all ledgers) |
| One session in two ledgers | structurally blocked on the write side (attach check); detected at load → throw (externally hand-edited data, never masked) |
| The workspace directory does not exist | record and ledger stay; `status()` = `'missing-dir'`; the storage layer never auto-deletes (the directory may only be temporarily moved) |
### Reuse and the session-backend migration outlook
**Long-term direction**: the pure medium operations inside session-persistence's JSONL/SQLite backends sink into `dsh-storage` backends (the session packages stay; the `SessionPersistence` seam and coordinator semantics do not move — only the file/db operation layer beneath them does). The motive for reuse: the medium layer is all filesystem operations, database calls, and cross-platform grit (Windows permission and atomic-publish variants, fsync semantics, exclusive file creation…), which should be written once; business semantics (how a session appends, when, and what) stay above — while "did this append complete correctly underneath" (durability/atomicity/platform correctness) is the lower layer's responsibility, and the responsibility boundary is the facet primitive contract. The backend interface is therefore designed as **medium owner + data-shape facets**: a session log is an append-only stream, a different shape from KV — forcing them into one set of primitives would deform both, so facets split them (`kv` this phase, `log` at migration) while sharing the medium and its lifecycle.
The current reuse audit (an account already legible before the migration):
| Existing session-persistence logic | Nature | Disposition |
| --- | --- | --- |
| JSONL: temp write + fsync + link/unlink atomic publish, 0o700/0o600 permissions, Windows variant (win32.ts) | pure medium | copied by `dsh-storage-json` this phase (whole-file atomic rewrite is the same protocol); becomes the shared implementation at migration |
| JSONL: line-append, first-line header fast read, zstd per-frame compression | log shape | stays put; moves into the `log` facet at migration |
| SQLite: openDatabase (mkdir/exclusive create/PRAGMA sequence/user_version check) | pure medium | copied by `dsh-storage-sqlite` this phase — the two openDatabase copies are already near line-identical and this group is the third user; copy now, extract at migration |
| SQLite: events/sessions schema, same-transaction materialization | log shape | stays put; moves into the `log` facet at migration |
| coordinator (per-id write chain, lazy materialization, crash repair, flush barrier) | session semantics | never sinks — event-log domain logic whose counterpart here is the domain layer's write chain; each owns its own |
| encodeSegment (id-to-path escaping) | medium utility | unused on the domain side (keys never reach paths); sinks together with the `log` facet (one file per session) at migration |
**This phase does not touch session-persistence's medium code** (only the delete primitive is added); the table above is the migration-phase work list and the design evidence that the backend interface must accommodate the log shape.
### Test matrix
| Suite | Coverage | Backends |
| --- | --- | --- |
| backend contract (shared suite, written once, run on both) | the seven contract clauses + version rejection + close idempotence | json, sqlite (`:memory:` + temp dirs) |
| registry/mount | duplicate registration, unmounted access, disposer removal | — |
| domain layer | the six open steps, schema rejection, update serialization (concurrent interleaving stress), `domain/changed` per record, global initial-value lazy materialization, routing and `facet-unsupported` | either (json) |
| workspace | create/uniqueness/realpath, attach checks (including rejection when sessionPersistence is absent), the four consistency-doctrine cases | mock domain or json |
| session delete contract (future work, joins runPersistenceContract at implementation) | unknown id, deleted-id reuse, un-materialized intent, serialization with in-flight appends, the deleted event | jsonl, sqlite |
Snapshots: no model-visible or assembly surface this phase, none added; next phase's RPC wiring brings them with the `workspace.*` domain.
### Out-of-scope list
| Not doing | Trigger | Rework point | Groundwork |
| --- | --- | --- | --- |
| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with |
| The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already |
| Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only |
| Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process |
| Data migration | model changes after the first tagged release | version-driven per-domain migration | versions are on the medium from day one |
| Large-table performance | a thousand-record domain routed to json | point `routes` at sqlite, migrate the data by hand once | routing is configuration; consumers unchanged |
| Multi-segment keys | a real two-segment consumer appears (per-workspace per-session dimension data) | key generics become tuples, SQLite composite primary keys, JSON nested levels | single-level tables are the one-segment special case; no arbitrary-depth nesting; no string-concatenated keys |
| The scope dimension | a "one per workspace" domain appears and composite keys cannot express it | DomainSpec gains a scope declaration + a scope segment in file names (encodeSegment) | the name character set is already restricted; file names cannot collide |
| Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — |
| Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow |
| Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — |
| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection |
## Alternatives considered
- **Reusing session-persistence's coordinator/backends**: event-log semantics (append-only, turn crash repair, lazy materialization) do not match KV overwrite semantics; only the layering idea is borrowed (a coordination layer owns write ordering, backends implement minimal primitives).
- **A workspace-specific storage package, seam extracted later**: the second consumer (the session sidecar) is already foreseeable; generalizing later means touching the interface twice.
- **Merging domain and storage into one layer**: backends would be forced to touch schema validation, change events, and write serialization — domain concerns; split apart, storage backends implement only opaque primitives (the smallest replaceable surface) while the single domain implementation concentrates all domain logic (zod/events/serialization written once, not doubled per backend).
- **JSON backend as jsonl append + tombstones + compaction**: temp+fsync+rename crash safety is equivalent to append; rewriting keeps the file the net current state, human-readable, with no folding/compaction/torn-line tolerance; at domain scale a full rewrite costs the same as appending a line.
- **JSON one file per table**: under whole-file rewrites the file granularity does not affect write cost; merging per domain means fewer files and gives the global singleton a home.
- **SQLite storing a whole domain as one blob row**: any single-record change rewrites the whole domain, forfeiting per-key precise updates — SQLite's only edge over JSON reduced to zero.
- **SQLite generating typed columns from the schema**: a DDL generator is over-engineering; document-per-row suffices, revisit when real query needs appear.
- **One sqlite db file per domain**: contrary to the repository's one-database-many-tables convention.
- **A single whole-store backend choice (the session-persistence single-slot pattern)**: the initial design; changed to coexisting backends + configured routing because the hub will carry multiple data forms whose backend preferences (human-readable vs high-frequency point updates) are bound to diverge — a single slot forces the coarse "swap everything + hand-migrate data" move. The cost is one extra name lookup, backed by fail-loud.
- **path as the workspace key**: normalization/symlink resolution rewrites the path; reference anchors must be stable.
- **Ownership derived from cwd (or merged with the ledger)**: two sources of truth; cwd cannot express ordering; ownership is a workspace-side fact to begin with.
- **Change events carrying the old value**: the repository's change-event convention is "new snapshot + operation discriminant" (the sole exception, fs's before/after, is a method return value rather than an event, because the old value is unrecoverable afterwards and has a diff consumer); consumers needing diffs hold their own previous snapshot.
- **Delete auto-cancelling a running session**: the persistence/orchestration layer reaching back into the runtime dirties the layering; cancel already exists, callers compose it.
## Acceptance criteria
- This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine).
- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work).
- Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase).
- No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring.
## Risks
- **The repository's first push-mode change event on a persistence surface** (session-persistence polls revisions): the shape has the `goal/changed` template, but "the storage layer emits events" is a new precedent, validated only when next phase's RPC consumes it.
- **The JSON backend's whole-unit rewrite scale premise**: if the second consumer (the session sidecar) lands on the JSON backend at thousand-record scale before being routed to SQLite, the rewrite cost surfaces earlier than expected; the mitigation is exactly `routes` pointing at sqlite.
- **The deletion orchestration's weak dependency on `ctx.sessions`**: a headless assembly without the runtime registry treats it as "no hot sessions", leaving a window (an external process running the session); multi-process is already out of scope, accepted.
- **Facet generalization designed against the future `log` facet without implementing it this phase**: a "reserved shape does not fit" risk; mitigated by organizing both backends' medium code in the sinkable shape from the reuse audit, so when the `log` facet lands only the facet layer moves.

View File

@@ -0,0 +1,329 @@
# Agent Note: Domain KV storage capability seam and the workspace entity
Status: proposed
[English](2026-07-24-domain-kv-storage-and-workspace.md) | 中文
## Problem
host 侧唯一的持久化面是 session 事件日志(`packages/session-persistence`append-only、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求:
- **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。
- **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header创建时的不可变快照title、结束状态这类随会话推进变化的信息拿不到补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。
另外workspace 删除最终需要删除其关联 session`SessionPersistence` 没有删除原语host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work本期不动 session 侧任何代码。
## Proposal
新建 `packages/storage/` 组——`ctx.storage` 存储枢纽(后端注册面 + 数据形式挂载面、两个后端、domain 领域数据形式——及 workspace 消费者包;给 `SessionPersistence` 扩删除原语。
| 包 | 路径 | ctx 面 | 本期 |
| --- | --- | --- | --- |
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage`(枢纽) | ✓ |
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | 注册 backend `json` | ✓ |
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | 注册 backend `sqlite` | ✓ |
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | 挂载 `ctx.storage.domain` | ✓ |
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
| `SessionPersistence.delete` 扩面 + 级联删编排 | `packages/session-persistence/*` | 既有 seam 新方法 | ✗ future work本期不动 session 侧) |
| `workspace.*` / `session.delete` RPC、GUI 接线、boot 组装 | — | — | ✗ 下期 |
workspace 放独立组不放 `packages/host/`host 组命名规则要求 `dsh-host-*` 前缀,而包名定为 `dsh-workspace`;且 workspace 实体是领域概念,不绑定 host 装配层。与既有 `workspace-context` 包无关——那是 AGENTS.md 指令加载器。)
依赖方向:`dsh-workspace``dsh-domain``dsh-storage` ← 两后端。`dsh-workspace` 另依赖 `ctx.sessionPersistence` 的只读面attach 的 cwd 校验读 session header服务缺席时 attach 直接拒绝——无法校验即不写账。session 删除相关的 `ctx.sessions` 运行中检查随级联删一并归入 future work。
### `dsh-storage`:存储枢纽
纯注册枢纽,自身不做 IO无 Config。`Storage` service 挂 `ctx.storage`,两个面:`backend``BackendRegistry``register(name, backend)` 返回 disposer、重名 throw`get(name)` 未知名 throw `backend-not-found`)与数据形式挂载(`mount(form, facility)` 配 merge-extensible 的 `StorageForms` map`dsh-domain` merge 进 `domain` 键;未挂载访问 throw `form-not-mounted`)。签名正文见 `packages/storage/storage/src/index.ts``src/registry.ts`
**多后端同时挂载**;域→后端的选择是 `dsh-domain` 的配置见下不是全局二选一。disposer 语义 = 从表中摘名;后端自身的 close 由后端包的 effect 闭包负责,顺序先摘名后 close。
一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`session 迁移期加 `log`见迁移节。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud。`kv` facet 的原语面:`open(descriptor)`descriptor = 名字/版本/表名清单/有无 global名字与表名限 `^[a-z][a-z0-9_]*$` 兼作文件名与 SQL 表名段)返回 unitunit 提供 `loadAll` / `putRecord` / `deleteRecord`(缺 key 为 no-op/ `setGlobal` / `close`(幂等);值对后端是不透明 JSON。规范正文含逐方法 JSDoc`packages/storage/storage/src/backend.ts`
backend 契约(共享契约测试逐条断言,两后端同套件):
1. `open` 对不存在的介质创建(懒物化允许:可延迟到首写,但 `loadAll` 立即可用返回空表);对已存在介质载入。
2. 介质上版本 ≠ descriptor.version → `StorageError('version-mismatch')`,不迁移不重建。
3. 持久性:写原语 resolve 后进程崩溃再 open`loadAll` 必须反映该写入。
4. 后端不承诺 unit 内写并发序——**调用方负责串行**后端只保证单次调用原子JSON 整文件替换 / SQLite 单语句)。
5. `deleteRecord` 幂等;`putRecord` 覆写。
6. 任意字符串 key / 任意 JSON 值安全key 不进文件路径,结构性质)。
7. `close` 幂等close 后任何操作 → `StorageError('closed')`
错误词汇是带 code 判别的 `StorageError`,码表:`backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed``packages/storage/storage/src/error.ts`)。
### `dsh-storage-json`
Config 仅 `root`必填无默认schemasteryapply 在 `ctx.effect()` 里注册后端 `json`disposer 先摘名再 `backend.close()`
- 布局 `<root>/<unitName>.json`,一 unit 一文件;目录 0o700、文件 0o600。
- 文件格式(版本戳在头,文件即当前净值,`JSON.stringify(…, null, 2)` 肉眼可读——这是该后端的存在理由):
```json
{
"unit": { "name": "workspace", "version": 1 },
"global": null,
"tables": { "workspaces": { "<key>": {} } }
}
```
- 写入:任何一次写原语 = 内存态全量序列化 → temp 写 + fsync → rename 原子发布Windows 变体照抄 session-persistence-jsonl 的 win32 路径)。内存态是权威,盘是投影。
- `loadAll`open 时整文件 parse`unit` 头、tables 非对象等 → `malformed-medium`。文件不存在 = 空单元,首写才落盘。
### `dsh-storage-sqlite`
Config 为 `path`(必填,`':memory:'` 允许)+ `journalMode`(枚举,默认 `wal`apply 同 json注册后端 `sqlite`
- `node:sqlite` `DatabaseSync`;打开序列照抄 session-persistence-sqlitemkdir 0o700 → 不存在则 `open(path,'wx',0o600)` 独占建文件 → `PRAGMA foreign_keys=ON` → journal_mode → 版本检查 → 建表。
- 物理布局版本 `STORAGE_SQLITE_SCHEMA_VERSION = 1``PRAGMA user_version`0 → 盖章;≠ → `version-mismatch`
- DDL全 STRICT表名由受限字符集拼接加 `u_` 前缀,杜绝外部输入进 DDL
```sql
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
CREATE TABLE IF NOT EXISTS unit_globals (
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
-- 每 unit 每表:
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
```
- unit 版本存 `units`descriptor 不符 → `version-mismatch`。行粒度 document-per-row保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
- 写原语单语句即原子无跨语句事务需求domain 层无跨表事务,见不做清单)。
### `dsh-domain`:领域数据形式
单实现不抽象;消费者只依赖这层,不直接触后端。
```ts ignore-check
export const Config = z.object({
backend: z.string().required(), // 默认后端名,必填
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
})
export function apply(ctx: Context, config: Config) {
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
}
```
facility 卸载顺序:先 dispose 各域(排空写链)再从枢纽摘名——排空期间在途写仍发 `domain/changed`,事件一致性 invariant 经 facility 反查域,要求此时域名仍可解析。)
域声明spec 对象由拥有该域的包定义导出是类型与运行时的单一来源schema 用 zod`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schemawire 边界全是 zodschemastery 仍只管插件 Config
```ts ignore-check
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
export interface DomainSpec {
readonly name: string // ^[a-z][a-z0-9_]*$
readonly version: number
readonly global?: DomainGlobalSpec<unknown>
readonly tables: Record<string, DomainTableSpec<string, unknown>>
}
export function defineDomain<S extends DomainSpec>(spec: S): S
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
```
`DomainFacility.open(spec)` 精确语义(顺序执行,任一步失败即整体失败):
1. 同名域已打开 → `DomainError('already-open')`。
2. 后端名 = `config.routes[spec.name] ?? config.backend``ctx.storage.backend.get(name)`(未挂载穿透 `backend-not-found`——misconfiguration fails loud
3. 后端无 `kv` facet → `DomainError('facet-unsupported')`。
4. `kv.open(descriptorOf(spec))`descriptor 由 spec 直接投影)。
5. `loadAll()`;每条记录 `valueSchema.parse`global 过 schemanull 取 `initial`,不落盘,首写才落盘)。失败 → `DomainError('invalid-record', { table, key })`durable 边界必须校验;写侧不重复校验)。
6. 构造 `Domain` 并注册 `ctx.effect()`disposer 排空写链 → `unit.close()`。
```ts ignore-check
export interface Domain</* 由 spec 推导 */> {
readonly name: string
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
}
export interface KvTable<K extends string, V> {
get(key: K): V | undefined // 内存快照,同步
entries(): IterableIterator<[K, V]>
keys(): IterableIterator<K>
readonly size: number
put(key: K, value: V): Promise<void>
delete(key: K): Promise<boolean> // false = 本就不存在
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
}
```
规则:
- **一级 mapping**key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构JSON object 一层 ↔ SQLite 一行)。
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO`get`/`entries` 返回值不得原地改TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费者包。
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record取出可变对象自动落盘——落盘时机不可控与整域原子覆写冲突
- **版本 fail loud**:盘上版本与 spec 不符直接报错不迁移不重建数据不可再生pre-release 拒绝旧格式)。
- **变更事件**:每次写落盘 resolve 后 emit `domain/changed``@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + keyglobal 变更两者为 `''`+ operationput 支带新快照 value、deleted 支无 value`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`/ `missing-key` / `closed`。
### Future worksession 侧删除(设计定案,本期不实施)
本节是定案的施工规范,实施期不动语义只动代码;本期 session-persistence 的任何文件都不修改。
```ts ignore-check
export abstract class SessionPersistence extends Service {
/**
* Permanently delete one session's stored log.
* Queued on the per-id write chain (serialized with in-flight appends).
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
* After deletion the id behaves as unknown for every subsequent operation.
*/
abstract delete(id: SessionId): Promise<void>
}
```
- JSONL 后端unlink 该 session 文件(含 `.zstd` 变体);文件与 intent 均无 → reject。
- SQLite 后端:单事务 `DELETE FROM events…; DELETE FROM sessions…`0 行命中且无 intent → reject。
- 删除成功后 emit `'session-persistence/deleted'(id: SessionId)``@mode emit`session-persistence 层事件面,与 `domain/changed` 无关。派生数据session-query 全文索引等)订阅自清;持久层不直连索引,崩溃窗口靠派生索引可丢弃重建兜底。
编排层规则(随级联删一起实施;`session.delete` RPC 与 workspace 级联复用同一规则):
| 检查(按序) | 不满足时 |
| --- | --- |
| 目标(递归时含整棵子树)无一在 `ctx.sessions` 运行 | throw什么都不删调用方先 cancel 再删,持久层不反向牵动运行时 |
| 非递归时目标无后代(后代 = `parentSessionId` 传递闭包,由 `list()` header 求得) | throw默认只能删叶子`recursive: true` 显式递归 |
| 递归序自底向上(叶→根) | ——中途崩溃只留"子树删一半、祖先在",重跑收敛,任何时刻无悬空 parent |
| 级联中某 id 已不在盘上 | 跳过(幂等续删);其余错误中止 |
### `dsh-workspace`
包拥有 `WorkspaceId` brand暴露 `ctx.workspace`。记录 key 为生成的 uuid——path 不做 key规范化会改写它引用锚点必须稳定。
```ts ignore-check
export type WorkspaceId = Branded<'WorkspaceId'>
export function WorkspaceId(id: string): WorkspaceId
const workspaceRecord = z.object({
path: z.string(), // realpath见下
title: z.string(),
sessionIds: z.array(z.string().transform(SessionId)),
createdAt: z.string(), // ISO
updatedAt: z.string(),
})
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
export const workspaceDomainSpec = defineDomain({
name: 'workspace', version: 1,
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
})
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
export interface Workspace {
readonly id: WorkspaceId
readonly path: string
readonly title: string
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
setTitle(title: string): Promise<void>
/** Record a session under this workspace (idempotent). Rejects when the session
* header's cwd (realpath) differs from this workspace's path. */
attachSession(sessionId: SessionId): Promise<void>
detachSession(sessionId: SessionId): Promise<void>
/** Live directory check, uncached. */
status(): Promise<'ok' | 'missing-dir'>
}
export class WorkspaceRegistry extends Service {
constructor(ctx: Context) // super(ctx, 'workspace')
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
get(id: WorkspaceId): Workspace | undefined
list(): Workspace[]
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
// deletefuture work与 session 级联删一起做,见下);本期不提供任何删除入口
}
```
- **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 rejectrealpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace双重记账写侧不可能。
- **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实headless 直开的 session 不属于任何 workspace。
- 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam实体按 id 唯一registry 缓存),记录快照写后原地换新,外部只见 getter所有写收敛到实体内 `mutate(fn)` → `table.update``updatedAt` 在 mutate 内统一刷。领域对象不过 RPC下期 wire 层把记录投影成 zod wire schema。
- **workspace 删除整体为 future work**2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。
一致性口径(账 = 归属唯一依据;实现与测试基准):
| 情形 | 行为 |
| --- | --- |
| 账中 id 盘上无 session | `list()`/实体投影时过滤;下次任何 mutate 顺手摘除;不报错(删除崩溃一致性的正常产物) |
| session cwd 匹配某 workspace 但未上账 | 不属于不合并不收编。GUI 将来可做"游离 session"专区(游离 = 全部账的补集) |
| 同一 session 上两本账 | 写侧结构性堵死attach 校验load 检出 → throw外部手改数据不掩盖 |
| workspace 目录不存在 | 记录与账保留,`status()` = `'missing-dir'`;存储层不自动删(目录可能只是暂时挪走) |
### 复用与 session 后端迁移展望
**长期方向**session-persistence 的 JSONL/SQLite 后端里"纯介质操作"下沉到 `dsh-storage` 后端session 包不删,`SessionPersistence` seam 与 coordinator 语义不动;动的只是它们脚下的文件/db 操作层。复用的动机介质层全是文件系统操作、数据库调用与跨平台兼容的脏活Windows 权限与原子发布变体、fsync 语义、独占建文件……这些只应写一遍业务语义session 怎么 append、何时 append、append 什么)留在上层——而"底下这次 append 是否正常完成"(持久性/原子性/平台正确性)是底层的责任,责任界面就是 facet 原语的契约。为此后端接口按**介质 owner + 数据形状 facet** 设计session 日志是 append-only 流,与 KV 形状不同——强行统一进 KV 原语会两头变形,所以按 facet 分开(`kv` 本期、`log` 迁移期),介质与生命周期共享。
现状复用审计(迁移前就能看清的账):
| session-persistence 现有逻辑 | 归属 | 处置 |
| --- | --- | --- |
| JSONLtemp 写 + fsync + link/unlink 原子发布、0o700/0o600 权限、Windows 变体win32.ts | 纯介质 | 本期 `dsh-storage-json` 直接抄用(整文件原子覆写正是同一套);迁移期成为共享实现 |
| JSONL逐行 append、首行 header 快读、zstd 逐帧压缩 | log 形状 | 留在原地;迁移期进 `log` facet |
| SQLiteopenDatabasemkdir/独占建文件/PRAGMA 序列/user_version 检查) | 纯介质 | 本期 `dsh-storage-sqlite` 抄用——两处 openDatabase 已几乎逐行同构,本组是第三个使用者;先抄后提,提取放迁移期 |
| SQLiteevents/sessions 表结构、同事务物化 | log 形状 | 留在原地;迁移期进 `log` facet |
| coordinatorper-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,对应物在 domain 层(写串行链),各归各 |
| encodeSegmentid 进路径转义) | 介质工具 | domain 侧 key 不进路径用不到;`log` facet一 session 一文件)迁移时随之下沉 |
**本期不改 session-persistence 的介质代码**(只加 delete 原语);上表是迁移期的施工清单,也是后端接口"必须装得下 log 形状"的设计依据。
### 测试矩阵
| 套件 | 覆盖 | 后端 |
| --- | --- | --- |
| backend 契约(共享套件,一次编写两端跑) | 七条契约 + 版本拒绝 + close 幂等 | json、sqlite`:memory:` + 临时目录) |
| registry/mount | 重复注册、未挂载访问、disposer 摘除 | — |
| domain 层 | open 六步语义、schema 拒绝、update 串行(并发交错压测)、`domain/changed` 逐条、global 初值懒物化、路由与 `facet-unsupported` | 任一json |
| workspace | create/唯一性/realpath、attach 校验(含 sessionPersistence 缺席拒绝)、一致性口径四情形 | mock domain 或 json |
| session delete 契约future work随实施并入 runPersistenceContract | 未知 id、已删 id 复用、未物化 intent、与在途 append 串行、deleted 事件 | jsonl、sqlite |
快照:本期无模型可见面与组装面,不新增;下期 RPC 接线时随 `workspace.*` 域补。
### 不做清单
| 不做 | 触发条件 | 返工点 | 预埋 |
| --- | --- | --- | --- |
| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动GUI 需要删除交互前) | 按上文 future work 节实施session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note本期无任何删除入口无半截语义要兼容 |
| `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 |
| 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 |
| 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence | 进程内已有 `domain/changed` |
| 数据迁移 | 首个 tagged release 后模型再变 | 版本号驱动逐域迁移 | 版本号自第一天入介质 |
| 大表性能 | 千级记录域挂 json | `routes` 改指 sqlite数据手工导一次 | 路由即配置,消费者零改动 |
| 多段 key | 两段 key 消费者出现(每 workspace 每 session 维度数据) | key 泛型换 tuple、SQLite 复合主键、JSON 嵌套层 | 一级表 = 段数 1 特例;不做任意深度嵌套;不拼字符串 key |
| scope 维度 | "每 workspace 一份"的域出现且复合 key 表达不动 | DomainSpec 加 scope + 文件名 scope 段encodeSegment | 名字字符集已收紧,文件名不冲突 |
| 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`JSON 天然原子SQLite 包事务 | — |
| 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 |
| session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — |
| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 |
## Alternatives considered
- **复用 session-persistence 的 coordinator/后端**事件日志语义append-only、turn 崩溃修复、懒物化)与 KV 覆写语义不匹配;只借其分层思想(协调层持写序、后端只实现最小原语)。
- **workspace 专用存储包,后续再抽 seam**第二个消费者session sidecar已可预见届时泛化要再动一次接口。
- **domain 与 storage 合为一层**:后端会被迫接触 schema 校验、变更事件、写串行等领域关切;拆开后 storage 后端只做不透明原语可替换面最小domain 单实现收敛全部领域逻辑zod/事件/串行化只写一遍,不随后端翻倍)。
- **整库单后端二选一(学 session-persistence 单坑位模式)**:曾是初版方案;改为多后端并存 + 配置路由,因为存储枢纽要承载多种数据形式,不同形式/域对后端的偏好(肉眼可读 vs 高频点更新)注定分化,单坑位会逼出"整体换挂 + 手工导数据"的粗粒度动作。代价是按名查找多一步fail-loud 兜底。
- **JSON 后端 jsonl 追加 + 墓碑 + 压实**temp+fsync+rename 的崩溃安全与 append 等价;覆写让文件永远是净值、肉眼可读,免掉折叠/压实/断行容错。域规模下整写与追加一行同量级。
- **JSON 一表一文件**覆写下文件粒度不影响写成本按域合并文件更少global 单例有落点。
- **SQLite 整域存单行 blob**:任何一条记录变更都重写整域,失去按 key 精确更新——SQLite 相对 JSON 的唯一优势归零。
- **SQLite 按 schema 生成 typed columns**DDL 生成器过度建设document-per-row 足够,查询需求出现再议。
- **每域独立 sqlite db 文件**:与仓库一库多表惯例相反。
- **path 作为 workspace key**:规范化/符号链接解析会改写 path引用锚点必须稳定。
- **归属用 cwd 派生(或与账合并)**双真相源cwd 表达不了排序;归属本就是 workspace 侧事实。
- **变更事件带旧值**:仓库变更事件惯例是"新快照 + 操作判别"(唯一例外 fs 的 before/after 是方法返回值而非事件,因旧值事后不可重建且有 diff 消费者);需要 diff 的消费者自己持有上次快照。
- **删除自动 cancel 运行中 session**:持久层/编排层反向牵动运行时层次变脏cancel 机制已存在,调用方组合即可。
## Acceptance criteria
- 测试矩阵本期四套件全绿backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud、workspace 全语义create/attach 校验/一致性口径)。
- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work
- session-persistence 包零 diff本期不动 session 侧的验收线)。
- 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。
## Risks
- **仓库持久化面第一个推式变更事件**session-persistence 靠 revision 轮询):形态虽有 `goal/changed` 范本,但"存储层发事件"是新先例,下期 RPC 消费时才能验证形态是否合适。
- **JSON 后端整域覆写的规模前提**若第二个消费者session sidecar在路由到 SQLite 前就以千级记录落在 JSON 后端,整写成本会先于预期显现;缓解即 `routes` 改指 sqlite。
- **删除语义的编排层检查依赖 `ctx.sessions` 弱依赖**headless 组装拿不到运行时注册表时按"无热 session"处理,存在窗口(外部进程正在跑该 session多进程本就在不做清单内接受。
- **facet 泛化以未来的 `log` facet 为设计依据但本期不实现它**:存在"预留形状不合身"的风险;缓解是本期后端介质代码按复用审计表的下沉形状组织,`log` facet 真正落地时只动 facet 层。