diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 9bd0aa88ff..87ce1f1bac 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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 -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 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index c8869f068b..3513e02678 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -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//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//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 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 1ceaba1b36..f31a1b076a 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -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//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//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` 帧——这是一条系统级 SSE(Server-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` 帧——这是一条系统级 SSE(Server-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 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 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml new file mode 100644 index 0000000000..795cb97082 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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 +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 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md new file mode 100644 index 0000000000..9e93b828d5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -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 10–25% 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 10–25% 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 | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md new file mode 100644 index 0000000000..996a5705bd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -0,0 +1,42 @@ +# Agent Note:dsh 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-tunables),client 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 runtime(32 行)、`api-gateway` 行、`webserver` 行、十个 `dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 + +**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > 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 层工厂全部注册完——否则有实测 10–25% 的 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()` 去重为安全依据) | 被 10–25% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 | +| json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,写入方要懂 cordis | diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml new file mode 100644 index 0000000000..5ab7a8229d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.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 +2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f +2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md new file mode 100644 index 0000000000..cd666a47a3 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -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 `/.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": { "": {} } } +} +``` + +- 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__" ( + 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 { readonly schema: ZodType; readonly initial: G } +export interface DomainTableSpec { readonly valueSchema: ZodType } + +export interface DomainSpec { + readonly name: string // ^[a-z][a-z0-9_]*$ + readonly version: number + readonly global?: DomainGlobalSpec + readonly tables: Record> +} + +export function defineDomain(spec: S): S +export function domainTable(schema: ZodType): DomainTableSpec +``` + +`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 { + readonly name: string + readonly global: { get(): G; set(value: G): Promise } // 仅当 spec.global 声明 + table(name: N): KvTable, ValueOf> +} + +export interface KvTable { + get(key: K): V | undefined // 内存快照,同步 + entries(): IterableIterator<[K, V]> + keys(): IterableIterator + readonly size: number + put(key: K, value: V): Promise + delete(key: K): Promise // false = 本就不存在 + /** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */ + update(key: K, fn: (current: V) => V): Promise // 缺 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 +} +``` + +- 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 + +export const workspaceDomainSpec = defineDomain({ + name: 'workspace', version: 1, + tables: { workspaces: domainTable(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 + /** 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 + detachSession(sessionId: SessionId): Promise + /** 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 重建 + create(path: string, title?: string): Promise // realpath 后撞已有 → reject + get(id: WorkspaceId): Workspace | undefined + list(): Workspace[] + resolveByPath(path: string): Promise // 同 realpath 口径,故 async + // delete:future 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. diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md new file mode 100644 index 0000000000..81adf1eb6b --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -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 表名段)返回 unit,unit 提供 `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`(必填无默认,schemastery);apply 在 `ctx.effect()` 里注册后端 `json`,disposer 先摘名再 `backend.close()`。 + +- 布局 `/.json`,一 unit 一文件;目录 0o700、文件 0o600。 +- 文件格式(版本戳在头,文件即当前净值,`JSON.stringify(…, null, 2)` 肉眼可读——这是该后端的存在理由): + +```json +{ + "unit": { "name": "workspace", "version": 1 }, + "global": null, + "tables": { "workspaces": { "": {} } } +} +``` + +- 写入:任何一次写原语 = 内存态全量序列化 → 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-sqlite:mkdir 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__
" ( + 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 schema,wire 边界全是 zod;schemastery 仍只管插件 Config): + +```ts ignore-check +export interface DomainGlobalSpec { readonly schema: ZodType; readonly initial: G } +export interface DomainTableSpec { readonly valueSchema: ZodType } + +export interface DomainSpec { + readonly name: string // ^[a-z][a-z0-9_]*$ + readonly version: number + readonly global?: DomainGlobalSpec + readonly tables: Record> +} + +export function defineDomain(spec: S): S +export function domainTable(schema: ZodType): DomainTableSpec +``` + +`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 过 schema(null 取 `initial`,不落盘,首写才落盘)。失败 → `DomainError('invalid-record', { table, key })`(durable 边界必须校验;写侧不重复校验)。 +6. 构造 `Domain` 并注册 `ctx.effect()`:disposer 排空写链 → `unit.close()`。 + +```ts ignore-check +export interface Domain { + readonly name: string + readonly global: { get(): G; set(value: G): Promise } // 仅当 spec.global 声明 + table(name: N): KvTable, ValueOf> +} + +export interface KvTable { + get(key: K): V | undefined // 内存快照,同步 + entries(): IterableIterator<[K, V]> + keys(): IterableIterator + readonly size: number + put(key: K, value: V): Promise + delete(key: K): Promise // false = 本就不存在 + /** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */ + update(key: K, fn: (current: V) => V): Promise // 缺 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 判别联合——域名 + 表名 + key(global 变更两者为 `''`)+ operation,put 支带新快照 value、deleted 支无 value(`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`)/ `missing-key` / `closed`。 + +### Future work:session 侧删除(设计定案,本期不实施) + +本节是定案的施工规范,实施期不动语义只动代码;本期 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 +} +``` + +- 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 + +export const workspaceDomainSpec = defineDomain({ + name: 'workspace', version: 1, + tables: { workspaces: domainTable(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 + /** 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 + detachSession(sessionId: SessionId): Promise + /** 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 重建 + create(path: string, title?: string): Promise // realpath 后撞已有 → reject + get(id: WorkspaceId): Workspace | undefined + list(): Workspace[] + resolveByPath(path: string): Promise // 同 realpath 口径,故 async + // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 +} +``` + +- **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——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 现有逻辑 | 归属 | 处置 | +| --- | --- | --- | +| JSONL:temp 写 + fsync + link/unlink 原子发布、0o700/0o600 权限、Windows 变体(win32.ts) | 纯介质 | 本期 `dsh-storage-json` 直接抄用(整文件原子覆写正是同一套);迁移期成为共享实现 | +| JSONL:逐行 append、首行 header 快读、zstd 逐帧压缩 | log 形状 | 留在原地;迁移期进 `log` facet | +| SQLite:openDatabase(mkdir/独占建文件/PRAGMA 序列/user_version 检查) | 纯介质 | 本期 `dsh-storage-sqlite` 抄用——两处 openDatabase 已几乎逐行同构,本组是第三个使用者;先抄后提,提取放迁移期 | +| SQLite:events/sessions 表结构、同事务物化 | log 形状 | 留在原地;迁移期进 `log` facet | +| coordinator(per-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,对应物在 domain 层(写串行链),各归各 | +| encodeSegment(id 进路径转义) | 介质工具 | 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 层。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml new file mode 100644 index 0000000000..a85f2eaf2c --- /dev/null +++ b/apps/cli/cordis.yml @@ -0,0 +1,224 @@ +# dsh web — the full web-shape composition: host runtime (layer 1), the +# transport/service layer (layer 2), and the browser plugin roster (dshClient +# rows the modules node half scans into window.__DSH_BOOT__). Row order +# carries no load semantics (activation is service-availability driven); the +# grouping below is for readers. `--dev` appends the dsh-client-hmr row in +# code (AppCLIEntry) — prod and dev differ by exactly that one row. +# AppCLIEntry patches this tree before boot: profile json + CLI flags + +# distIndex land as config patches over the rows below (yaml = engineering +# defaults, json = user config, user wins per field). + +# ── layer 1: runtime ──────────────────────────────────────────────────────── + +- id: timer + name: '@cordisjs/plugin-timer' + +- id: llm + name: '@deepseek-ai/dsh-llm' + +- id: session + name: '@deepseek-ai/dsh-session' + +- id: session-title + name: '@deepseek-ai/dsh-session-title' + config: + fallbackMaxWords: 5 + fallbackMaxBytes: 40 + maxTitleBytes: 80 + +# Model-made titles on the first-message cadence (the web sidebar renders +# session/title). Same values as the TUI composition. +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: '' + +- id: tools + name: '@deepseek-ai/dsh-tools' + +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +- id: agent + name: '@deepseek-ai/dsh-agent' + +- id: tasks + name: '@deepseek-ai/dsh-tasks' + +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: [] + +# The native DeepSeek adapter; reads the key/base-url the boot's layered +# .env loading (cwd then $DSH_HOME) left in the environment. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +- id: bash-local + name: '@deepseek-ai/dsh-bash-local' + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# fs cwd stays the package default (process.cwd()) — the same value the +# gateway injects into session.cwd, so paths and sessions agree. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +- id: skill + name: '@deepseek-ai/dsh-skill' + +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# token-meter rejects unknown config keys — keep this row bare. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +# Omitting maxInlineBytes makes the whole policy a silent no-op — always +# state it explicitly. +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 + +# The API gateway: the transport-agnostic dispatch face every client shape +# shares. provider/model are the host default routing — the profile json's +# mapping target (user config overrides these engineering defaults). +- id: api-gateway + name: '@deepseek-ai/dsh-host-apiproxy' + config: + provider: deepseek + model: deepseek-v4-flash + +# ── layer 2: transport/service ────────────────────────────────────────────── + +# Plain route-registration carrier. distIndex is an assembly fact, not user +# config — AppCLIEntry resolves the frontend dist and patches it in; host and +# port arrive as CLI-flag patches over these defaults. +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + config: + host: 127.0.0.1 + port: 3080 + +# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + +# Dual-face: node half scans this very tree for dshClient rows, composes +# window.__DSH_BOOT__, serves /plugins//client.js; browser half is the +# module table the shell kernel constructs before cordis exists (§4.7 — +# adopted as a plugin entry by the kernel, never fetched). +- id: modules + name: '@deepseek-ai/dsh-client-modules' + +# Owns both ends of the web transport: node half binds the gateway to the +# webserver under /api; browser half is the fetch/SSE client. +- id: connection + name: '@deepseek-ai/dsh-client-connection' + +- id: client-runtime + name: '@deepseek-ai/dsh-client-runtime' + +- id: ui-theme + name: '@deepseek-ai/dsh-client-ui-theme' + +- id: i18n + name: '@deepseek-ai/dsh-client-i18n' + +- id: ui-layout + name: '@deepseek-ai/dsh-client-ui-layout' + +- id: ui-sidebar + name: '@deepseek-ai/dsh-client-ui-sidebar' + +- id: ui-conversation + name: '@deepseek-ai/dsh-client-ui-conversation' + +- id: ui-question + name: '@deepseek-ai/dsh-client-ui-question' + +- id: ui-trajectory + name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/apps/cli/package.json b/apps/cli/package.json index 8557965d34..a39ce726bb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,14 +9,22 @@ }, "files": [ "lib/bin.js", + "cordis.yml", "src" ], "license": "BSD-3-Clause", "dependencies": { + "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-loader": "workspace:*", + "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-i18n": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", @@ -24,13 +32,48 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-spill-local": "workspace:^", + "@deepseek-ai/dsh-spill-policy": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "^4.0.0-rc.7", + "js-yaml": "^4.2.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9" } } diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts new file mode 100644 index 0000000000..52df4203b1 --- /dev/null +++ b/apps/cli/src/app-cli-entry.ts @@ -0,0 +1,231 @@ +/** + * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares + * (config-tree boot wired for `dsh web` this round; TUI/headless migrate + * later). Everything here is what must exist before the Loader runs: layered + * env, the patch composition over the shipped cordis.yml (profile json + CLI + * flags + the resolved frontend dist), and the fail-loud triple after the + * tree settles. + */ + +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import type { FiberState } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import yaml from 'js-yaml' +import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +// Empty type import carries the httpServer Context merge for the port read below. +import type {} from '@deepseek-ai/dsh-host-webserver' + +/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */ +const PROFILE_DIR = '.dsh-tmp-profile' +const PROFILE_FILE = 'config.json' + +/** One profile-json key mapped onto a yml row's config field. */ +interface ProfileMapping { + jsonPath: string + entryId: string + configKey: string +} + +/** + * The static profile→row mapping table. json is user config and wins over the + * yml engineering default per field; a json key absent from this table fails + * loud (a typo silently ignored would read as "setting has no effect"). + * Developers extend deployments by adding rows here. + */ +const PROFILE_MAPPINGS: ProfileMapping[] = [ + { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' }, + { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' }, + { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' }, +] + +// The include's YAML dialect: `!!js` scalars become expression nodes the +// Loader evaluates at entry activation. The bypass parse below must accept +// them (and passing one through a patch unchanged is legal). +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: data => ({ __jsExpr: String(data) }), +}) +const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** + * Value mirror of cordis's `FiberState` const enum members the sweep needs + * (a const enum has no runtime object to import; same rationale as the + * client-side mirror in dsh-client-web). + */ +const FIBER_ACTIVE = 2 as FiberState.ACTIVE +const FIBER_PENDING = 0 as FiberState.PENDING + +/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */ +export interface AppCLIEntryOptions { + /** Absolute path of the shipped cordis.yml. */ + configPath: string + /** Whether to append the HMR row (the whole prod/dev difference). */ + dev: boolean + /** --host when explicitly passed; undefined keeps the yml engineering default. */ + host?: string + /** --port when explicitly passed; undefined keeps the yml engineering default. */ + port?: number +} + +/** + * Boot driver for the config-tree `dsh web` shape: holds only what exists + * independently of (and prior to) cordis — argv facts, the composed patch + * set, and finally the root ctx. + */ +export class AppCLIEntry { + /** The root context, set by {@link run}. */ + ctx!: Context + + private patches: PatchOptions[] = [] + + constructor(private readonly options: AppCLIEntryOptions) {} + + /** + * Run the boot chain: layered env → patch composition → Loader include + * boot (dev row before await) → fail-loud triple. + * @returns the settled root context and the listening port. + */ + async run(): Promise<{ ctx: Context; port: number }> { + this.loadEnvLayers() + this.composePatches() + await this.bootTree() + this.assertBoot() + const port = this.ctx.get('httpServer')?.port + /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ + if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot') + return { ctx: this.ctx, port } + } + + /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ + private loadEnvLayers(): void { + loadEnv('dsh web', resolveDshHome()) + } + + /** + * Compose the patch set from the three non-yml config sources: profile + * json (user config), CLI flags, and the resolved frontend dist. Patches + * replace a row's config wholesale, so each patched row's yml static + * values are re-read here (bypass parse) and merged under the overrides. + */ + private composePatches(): void { + const rows = this.parseYmlRows() + const overrides = new Map>() + const put = (entryId: string, key: string, value: unknown): void => { + const bag = overrides.get(entryId) ?? {} + bag[key] = value + overrides.set(entryId, bag) + } + + // Source 1: profile json (missing file = empty; unmapped key = loud). + for (const [key, value] of Object.entries(this.readProfile())) { + const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) + if (mapping === undefined) { + throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) + } + put(mapping.entryId, mapping.configKey, value) + } + + // Source 2: CLI flags (field set disjoint from the json mappings). + if (this.options.host !== undefined) put('webserver', 'host', this.options.host) + if (this.options.port !== undefined) put('webserver', 'port', this.options.port) + + // Source 3: the frontend dist — an assembly fact of this app, never yml + // user config. Workspace knowledge stays here. + put('webserver', 'distIndex', this.resolveDistIndex()) + + this.patches = [...overrides.entries()].map(([id, bag]) => { + const yml = rows.get(id) + if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`) + return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } + }) + } + + /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */ + private async bootTree(): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { + path: pathToFileURL(resolve(this.options.configPath)).href, + ...this.patches.length > 0 ? { patches: this.patches } : {}, + }, + }) + if (this.options.dev) { + await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) + } + this.ctx = ctx + await ctx.loader.await() + } + + /** + * Fail-loud triple: assertEntriesLoaded catches import failures, + * installFailLoud catches late apply rejections, and the all-ACTIVE sweep + * below catches PENDING fibers (cordis inject waiting has no timeout). + */ + private assertBoot(): void { + installFailLoud('dsh web') + assertEntriesLoaded(this.ctx, 'dsh web') + const failures: string[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.fiber === undefined || entry.disabled) continue + const state = entry.fiber.state + if (state === FIBER_ACTIVE) continue + if (state === FIBER_PENDING) { + const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined) + failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + } else { + failures.push(`${entry.options.name}: fiber state ${String(state)}`) + } + } + if (failures.length > 0) { + throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + } + } + + /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ + private parseYmlRows(): Map { + const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`) + const rows = new Map() + for (const row of doc as { id?: string; config?: unknown }[]) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows + } + + /** Profile json under cwd; read-only — never created here, absent = no user config. */ + private readProfile(): Record { + let raw: string + try { + raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw error + } + const parsed: unknown = JSON.parse(raw) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) + } + return parsed as Record + } + + /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */ + private resolveDistIndex(): string { + const require = createRequire(import.meta.url) + try { + return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') + } catch { + throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') + } + } +} diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 7518804ebb..bcd1482df3 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,160 +1,66 @@ /** - * `dsh web` — the web-shape assembly: startHost + dist resolution + - * startWebServer + the URL line + signal wiring. Mixing host and carrier - * concerns is this app module's job (packages stay single-sided). + * `dsh web` — thin bin over the config-tree boot: parse argv, run + * AppCLIEntry, print the URL line, wire signals. All composition lives in + * cordis.yml; all boot glue lives in AppCLIEntry. */ import { parseArgs } from 'node:util' import { networkInterfaces } from 'node:os' -import { createRequire } from 'node:module' -import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' -import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +import { fileURLToPath } from 'node:url' +import { AppCLIEntry } from './app-cli-entry.ts' const LOOPBACK_HOST = '127.0.0.1' const ALL_INTERFACES_HOST = '0.0.0.0' -// --- Client composition (composition decisions live in the composing app) --- -// The composition layer owns one decision: which plugin packages mount (the -// roster). Dependency edges and the boot prefetch tier live in each package's -// dshClient declaration. - -/** - * Dev-only plugin: the client HMR driver. Whether it composes in is a - * deployment decision — the dev graph includes its row, the prod graph does - * not mount it at all. - */ -const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr' - -/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */ -const CLIENT_BUNDLE_POLL_MS = 500 - -/** The client plugin roster (flat; per-row boot behavior comes from manifests). */ -const CLIENT_PACKAGES = [ - '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-theme', - '@deepseek-ai/dsh-client-i18n', - '@deepseek-ai/dsh-client-ui-layout', - '@deepseek-ai/dsh-client-ui-sidebar', - '@deepseek-ai/dsh-client-ui-conversation', - '@deepseek-ai/dsh-client-ui-question', - '@deepseek-ai/dsh-client-ui-trajectory', -] as const +const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ args: argv, options: { - host: { type: 'string', default: LOOPBACK_HOST }, - port: { type: 'string', default: '3080' }, + host: { type: 'string' }, + port: { type: 'string' }, dev: { type: 'boolean', default: false }, }, allowPositionals: false, }) - if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { + if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { process.stderr.write( `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, ) process.exit(1) } - const hostAddress = values.host - const port = Number(values.port) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - process.stderr.write(`dsh web: invalid --port ${values.port}\n`) - process.exit(1) - } - - // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ - boot: { - persistenceRoot: './.sessions', - workspaceContext: { maxBytes: 65_536 }, - sessionTitleLlm: true, - }, - }) - - // Client plugin chain: in-memory Loader tree over the composed roster, then - // the registry that feeds the __DSH_BOOT__ entry graph and - // /plugins//client.js. All row content comes from dshClient discovery - // over the mounted roster (dev adds the HMR driver row and turns on the - // bundle watch that drives rebuilt frames). - const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []] - const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url) - const webPlugins = createHostWebPluginRegistry({ - ctx: host.ctx, - loader: mounted.loader, - resolvePkgJson: mounted.resolvePkgJson, - onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) }, - ...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {}, - }) - if (values.dev) { - // Dev visibility (the registry is a library and never prints): list what - // the bundle watch covers, then log every observed rebuild. This is a - // second onRebuilt subscription — the SSE relay inside the webserver is - // unaffected (multicast). - const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev])) - const bundlePaths = [...revs.keys()] - .map(id => webPlugins.clientPath(id)) - .filter((path): path is string => path !== undefined) - console.log( - `dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`, - ) - webPlugins.onRebuilt((id, rev) => { - console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`) - revs.set(id, rev) - }) - } - // Published so the webserver invariant companion can audit manifest/bundle - // consistency; nothing else reads this key. - host.ctx.reflect.provide('webPlugins', webPlugins) - - // Dist location is workspace knowledge of this app: resolved through - // @deepseek-ai/dsh-frontend's package exports, not configured. - const require = createRequire(import.meta.url) - let distIndex: string - try { - distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') - } catch { - process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n') - await host.dispose() - process.exit(1) - } - - let exiting = false - async function shutdown(code: number): Promise { - if (exiting) return - exiting = true - try { - await server.close() - await host.dispose() - } finally { - process.exit(code) + let port: number | undefined + if (values.port !== undefined) { + port = Number(values.port) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + process.stderr.write(`dsh web: invalid --port ${values.port}\n`) + process.exit(1) } } - let server: Awaited> - try { - server = await startWebServer( - { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, - (err: Error) => { - process.stderr.write(`dsh web: ${String(err)}\n`) - void shutdown(1) - }, - ) - } catch (error: unknown) { - // listen failed (EADDRINUSE…): no server to close, dispose the host directly. - process.stderr.write(`dsh web: ${String(error)}\n`) - await host.dispose() - process.exit(1) + const entry = new AppCLIEntry({ + configPath: CONFIG_PATH, + dev: values.dev, + ...values.host !== undefined ? { host: values.host } : {}, + ...port !== undefined ? { port } : {}, + }) + const { ctx, port: boundPort } = await entry.run() + + let exiting = false + const shutdown = (code: number): void => { + if (exiting) return + exiting = true + void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lan = hostAddress === ALL_INTERFACES_HOST + const lanCandidate = values.host === ALL_INTERFACES_HOST ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined - const localUrl = `http://${LOOPBACK_HOST}:${server.port}` - console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) + const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` + console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) - process.on('SIGTERM', () => { void shutdown(0) }) - process.on('SIGINT', () => { void shutdown(130) }) + process.on('SIGTERM', () => { shutdown(0) }) + process.on('SIGINT', () => { shutdown(130) }) } diff --git a/apps/web/package.json b/apps/web/package.json index a6b5a9b43f..3c8f90b6a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,7 +24,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 16ae6e9ed9..feb85db15a 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -3,8 +3,8 @@ * loader holding, module-table seeding, AppRoot gate, plugin assembly — lives * in @deepseek-ai/dsh-client-web; this file only finds the mount point. */ -import { bootWebShell } from '@deepseek-ai/dsh-client-web' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const el = document.getElementById('root') if (el === null) throw new Error('web app: missing #root') -bootWebShell(el) +void new AppWebEntry(el).run() diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 78023d5455..673e92f9ce 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules' -import { bootWebShell } from '@deepseek-ai/dsh-client-web' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, @@ -81,13 +81,15 @@ it('projects initial and revised durable titles through the built eight-plugin f const root = document.querySelector('#root') if (root === null) throw new Error('snapshot root missing') act(() => { - unmount = bootWebShell(root, { + const entry = new AppWebEntry(root, { fetchBundle: (url) => { const code = bundles.get(url) return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) }, executeBundle: (code) => { (0, eval)(code) }, }) + void entry.run() + unmount = () => { entry.dispose() } }) const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 }) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts deleted file mode 100644 index 0726d14c8b..0000000000 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ /dev/null @@ -1,291 +0,0 @@ -// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry -// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real -// chromium. First describe: graph injection + the fail-loud half. Second -// describe: the settled success pass — all nine REAL tsdown bundles load -// through the module system + vendored Loader chain in ?fixture mode (the -// infrastructure four ride the immediately prefetch tier, the UI rows fetch -// on demand), the three-column frame appears in one flip, and the resident -// question completes through the real UI stack. The full model round lands -// in smoke-real under the W5 real-host standard. -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { startWebServer } from '@deepseek-ai/dsh-host-webserver' -import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver' -import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts' - -const bundlePath = (dir: string): string => - fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) - -const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout' -const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar' - -/** id ↔ bundle table for the success pass (the complete Web UI assembly). */ -const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true }, - { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] }, - { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, -] - -const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)])) - -const row = (id: string, extra?: Partial): WebBootEntry => - ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra }) - -const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, { - ...(p.inject !== undefined ? { inject: p.inject } : {}), - ...(p.immediately === true ? { immediately: true } : {}), -})) - -/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */ -const FAIL_GRAPH: WebBootGraph = { - rev: 'e2e-fail', - entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')], -} - -/** Graph for the success pass: the complete assembly. */ -const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows } - -/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */ -function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap) { - return { - graph: () => graph, - clientPath: (id: string) => byId.get(id), - onRebuilt: () => () => undefined, - } -} - -describe('web boot chain (keyless, real carrier)', () => { - let server: Awaited> - let browser: Browser - let page: Page - const pageErrors: string[] = [] - - beforeAll(async () => { - requireDist() - const port = await probeFreePort() - const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) } - server = await startWebServer({ - host: '127.0.0.1', - port, - distIndex: DIST_INDEX, - apiHandler, - webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS), - }, (err) => { pageErrors.push(`server: ${String(err)}`) }) - browser = await chromium.launch() - page = await browser.newPage() - page.on('pageerror', e => pageErrors.push(String(e))) - await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' }) - }) - - afterAll(async () => { - await browser?.close() - await server?.close() - }) - - it('GET / injects the entry graph verbatim', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest')) - const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__) - expect(boot).toEqual(FAIL_GRAPH) - }) - - it('serves a real bundle through the plugins endpoint', async () => { - const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`) - expect(res.status()).toBe(200) - expect(await res.text()).toContain('window.__ModuleLoader__.load') - }) - - it('boots to the loading page and fail-louds the absent entry', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud')) - await page.waitForSelector('text=HARNESS', { timeout: 10_000 }) - await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 }) - await page.waitForSelector('text=@probe/absent', { timeout: 2000 }) - // The real UI must not have flipped in: the gate opens only on settled. - expect(await page.locator('[class*="frame"]').count()).toBe(0) - }) - - it('applies the token sheets before any plugin CSS', async () => { - const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family')) - expect(family.trim().length).toBeGreaterThan(0) - }) -}) - -describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => { - let server: Awaited> - let browser: Browser - let page: Page - const pageErrors: string[] = [] - - beforeAll(async () => { - requireDist() - const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) - if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter bundle): ${missing.map(m => m.dir).join(', ')}`) - const port = await probeFreePort() - // ?fixture never opens HTTP streams; /api is a tripwire like the first describe. - const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) } - server = await startWebServer({ - host: '127.0.0.1', - port, - distIndex: DIST_INDEX, - apiHandler, - webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS), - }, (err) => { pageErrors.push(`server: ${String(err)}`) }) - browser = await chromium.launch() - page = await browser.newPage() - page.on('pageerror', e => pageErrors.push(String(e))) - await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' }) - }) - - afterAll(async () => { - await browser?.close() - await server?.close() - }) - - it('settles and flips to the three-column frame in one pass', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled')) - await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) - // Loading page is gone; the grid carries the three tracks. - expect(await page.locator('text=Failed to load plugins').count()).toBe(0) - const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns) - expect(template.split(' ').length).toBe(3) - }) - - it('every plugin CSS landed with its ownership tag', async () => { - const owners = await page.evaluate(() => - [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) - expect(owners).toContain(LAYOUT_ID) - expect(owners).toContain(SIDEBAR_ID) - }) - - it('collapsed sidebar animates to a 56px rail with the four controls', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail')) - const frame = page.locator('[class*="frame"]') - const firstTrack = async (): Promise => (await frame.evaluate( - el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! - // The tracks transition on the deepsuite curve; assert the animated - // settle rather than an instant jump. - const settledTrack = async (px: string): Promise => { - await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) - } - // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome. - const brand = () => page.locator('[class*="brand"]').count() - await page.getByRole('button', { name: 'Collapse sidebar' }).click() - // Mid-collapse the wide chrome is still mounted, fading — not swapped out. - expect(await brand()).toBe(1) - await settledTrack('56px') - await expect.poll(brand, { timeout: 2000 }).toBe(0) - for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { - await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) - } - await page.getByRole('button', { name: 'Open sidebar' }).click() - await settledTrack('280px') - await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) - // Rail search: collapse again, the search control expands and lands in the box. - await page.getByRole('button', { name: 'Collapse sidebar' }).click() - await settledTrack('56px') - await page.getByRole('button', { name: 'Search sessions' }).click() - await settledTrack('280px') - // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it. - await expect.poll(() => page.evaluate(() => - (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search') - }) - - it('renders file tool rows and expands fixture reasoning from either click target', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure')) - await page.locator('[role="treeitem"]').first().click() - await page.locator('[role="treeitem"][aria-selected]').first().click() - - const thinkRoot = page.locator('[data-variant="think"]').first() - const think = thinkRoot.getByRole('button') - await think.waitFor({ state: 'visible', timeout: 10_000 }) - expect(await think.getAttribute('aria-expanded')).toBe('false') - - await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click() - expect(await think.getAttribute('aria-expanded')).toBe('true') - expect(await thinkRoot.locator(':scope > div').count()).toBe(2) - - await think.getByText('Think', { exact: true }).click() - expect(await think.getAttribute('aria-expanded')).toBe('false') - - const editRoot = page.locator('[data-variant="edit"]').first() - await editRoot.waitFor({ state: 'visible', timeout: 10_000 }) - expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1) - expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1) - - const writeRoot = page.locator('[data-variant="write"]').first() - await writeRoot.waitFor({ state: 'visible', timeout: 10_000 }) - expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1) - expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1) - }) - - it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream')) - await page.getByRole('button', { name: 'New session', exact: true }).click() - const input = page.locator('textarea[placeholder]') - await input.waitFor({ timeout: 15_000 }) - await input.fill('render markdown') - await page.getByRole('button', { name: '发送' }).click() - - const streaming = page.locator('[data-streaming="true"]') - await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 }) - await streaming.waitFor({ state: 'detached', timeout: 15_000 }) - - const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' }) - expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1') - expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1) - const external = page.getByRole('link', { name: 'DeepSeek' }) - expect(await external.getAttribute('target')).toBe('_blank') - expect(await external.getAttribute('rel')).toBe('noopener noreferrer') - }) - - it('renders and completes the resident question through the composer slot', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-question-composer')) - const sessionTree = page.getByRole('tree', { name: 'Sessions' }) - const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' }) - if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click() - await sessionTree.getByText('Fixture 历史会话', { exact: true }).click() - const composer = page.locator('[data-question-key]') - await composer.waitFor({ timeout: 15_000 }) - expect({ - question: await composer.getByRole('heading').innerText(), - progress: await composer.getByText('1 / 3', { exact: true }).innerText(), - options: await composer.getByRole('radio').allTextContents(), - custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(), - }).toMatchInlineSnapshot(` - { - "custom": "其他,请填写自定义答案", - "options": [ - "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。", - "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。", - "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。", - ], - "progress": "1 / 3", - "question": "你现在更想招哪类 Agent/Harness 候选人?", - } - `) - - await composer.getByRole('radio', { name: '工程落地型' }).click() - await composer.getByText('2 / 3', { exact: true }).waitFor() - await composer.getByRole('button', { name: '跳过本题', exact: true }).click() - await composer.getByRole('checkbox', { name: '系统设计' }).click() - await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click() - await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter') - - await composer.waitFor({ state: 'detached' }) - const restoredInput = page.locator('textarea[placeholder]') - await restoredInput.waitFor() - expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入') - }) - - it('stayed clean: no page errors across the whole load chain', () => { - expect(pageErrors).toEqual([]) - }) -}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index f4fbbb265f..ce0a6db799 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -16,10 +16,7 @@ export function requireDist(): void { } } -/** - * OS-assigned free port, released before use. startWebServer echoes - * options.port instead of the bound one, so passing 0 directly is unusable. - */ +/** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */ export function probeFreePort(): Promise { return new Promise((resolvePort, reject) => { const probe = createServer() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 998996304e..d0af0d4641 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,9 +21,6 @@ { "path": "../../packages/client/web" }, - { - "path": "../../packages/host/webserver" - }, { "path": "../../packages/client/modules" } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5a805cbeb3..7043de4911 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, - { find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') }, ], }, define: { diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..d66b5332f3 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,13 @@ flowchart LR pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] + pkg_storage["storage"] + svc_storage["ctx.storage
Non-session storage hub"] + pkg_storage_json["storage-json"] + pkg_storage_sqlite["storage-sqlite"] + pkg_storage_domain["storage-domain"] + pkg_workspace["workspace"] + svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -118,6 +125,12 @@ flowchart LR svc_spillStore["ctx.spillStore
Spill storage seam"] pkg_spill_local["spill-local"] pkg_spill_policy["spill-policy"] + pkg_webserver["webserver"] + svc_httpServer["ctx.httpServer
HTTP route registration"] + pkg_connection["connection"] + pkg_modules["modules"] + pkg_hmr["hmr"] + svc_clientModuleHost["ctx.clientModuleHost
Client plugin graph host"] pkg_workflow["workflow"] svc_workflows["ctx.workflows
Workflow script engine"] pkg_workflow_workerthread["workflow-workerthread"] @@ -145,6 +158,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode pkg_pty --> svc_pty @@ -166,6 +180,9 @@ flowchart LR pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore + pkg_storage --> svc_storage + pkg_storage_json --> svc_storage + pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -183,8 +200,10 @@ flowchart LR pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web pkg_web_search_perplexity --> svc_web + pkg_webserver --> svc_httpServer pkg_workflow --> svc_workflows pkg_workflow_workerthread --> svc_workflows + pkg_workspace --> svc_workspace svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -196,11 +215,15 @@ flowchart LR svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash + svc_clientModuleHost --> pkg_hmr svc_codeRuntime --> pkg_tools svc_commands --> pkg_acp svc_commands --> pkg_tui svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs + svc_httpServer --> pkg_connection + svc_httpServer --> pkg_hmr + svc_httpServer --> pkg_modules svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope @@ -235,6 +258,8 @@ flowchart LR svc_sessions --> pkg_subagent_inprocess svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy + svc_storage --> pkg_storage_domain + svc_storage --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop @@ -276,6 +301,8 @@ 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), [`acp`](../packages/ui/acp), [`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.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | - | 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.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | - | - | Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | @@ -303,6 +330,8 @@ flowchart LR | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | +| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | +| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..79fbdc48e2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -276,6 +276,20 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) +## `@deepseek-ai/dsh-client-hmr` + +Requires: `clientModuleHost` · `httpServer` + +```ts config-catalog +/** Plugin config, validated by the same-named schemastery schema. */ +export interface Config { + /** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */ + pollIntervalMs?: number +} +``` + +Source: [`packages/client/hmr/src/index.ts:29`](../packages/client/hmr/src/index.ts) + ## `@deepseek-ai/dsh-code-runtime-worker` ```ts config-catalog @@ -474,6 +488,38 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +## `@deepseek-ai/dsh-host-apiproxy` + +Requires: `agents` · `sessions` · `tools` · `userInteraction` + +```ts config-catalog +/** Gateway plugin config: the host-level default agent routing. */ +export interface Config { + /** Default provider route for created/resumed agents. */ + provider: string + /** Default model id. */ + model: string +} +``` + +Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) + +## `@deepseek-ai/dsh-host-webserver` + +```ts config-catalog +/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +export interface Config { + /** Listen host; the two supported values are loopback and all-interfaces. */ + host: '127.0.0.1' | '0.0.0.0' + /** Listen port; zero requests an OS-assigned port. */ + port: number + /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ + distIndex: string +} +``` + +Source: [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) + ## `@deepseek-ai/dsh-invariants` ```ts config-catalog @@ -1155,6 +1201,84 @@ export interface Config { Source: [`packages/spill/spill-policy/src/index.ts:51`](../packages/spill/spill-policy/src/index.ts) +## `@deepseek-ai/dsh-storage-domain` + +Requires: `storage` + +```ts config-catalog +/** + * Plugin config. Which backend serves which domain is decided here, not + * globally on the hub: `backend` is the default route and `routes` overrides + * it per domain name. A route naming an unregistered backend fails loud at + * `open` with `backend-not-found`. + */ +export interface Config { + /** Default backend name for every domain without an explicit route. Required: there is no universally correct medium. */ + backend: string + /** Per-domain overrides: domain name → backend name. */ + routes?: Record +} +``` + +Source: [`packages/storage/storage-domain/src/index.ts:45`](../packages/storage/storage-domain/src/index.ts) + +## `@deepseek-ai/dsh-storage-json` + +Requires: `storage` + +```ts config-catalog +/** + * Plugin configuration. + * `root` has NO default on purpose: a `process.cwd()` fallback would scatter + * unit files wherever the process happens to start; assemblies state the + * location explicitly. + */ +export interface Config { + /** Directory holding one `.json` file per unit. */ + root: string +} +``` + +Source: [`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts) + +## `@deepseek-ai/dsh-storage-sqlite` + +Requires: `storage` + +```ts config-catalog +/** Plugin configuration. */ +export interface Config { + /** + * Filesystem path to the SQLite database file. The special value `:memory:` + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing + * database fail the open. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. + */ + path: string + /** + * SQLite `journal_mode` pragma. `wal` (the default) suits local disks; pick + * a rollback-journal mode (`delete`/`truncate`/`persist`) on filesystems + * where WAL's shared-memory files do not work (network mounts). See + * {@link JournalMode}. + */ + journalMode?: JournalMode +} + +/** + * Journal modes the backend will run under. `wal` is the default; the + * rollback-journal modes (`delete`/`truncate`/`persist`) exist for + * filesystems where WAL's shared-memory files do not work (network mounts). + * `memory`/`off` are excluded: dropping journal durability silently + * contradicts the durability clause of the KV backend contract. + */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' +``` + +Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/storage-sqlite/src/index.ts) + ## `@deepseek-ai/dsh-subagent-acp` Requires: `subagents` @@ -1893,9 +2017,9 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-client-connection` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) -- `@deepseek-ai/dsh-client-hmr` ([`packages/client/hmr/src/index.ts`](../packages/client/hmr/src/index.ts)) +- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) - `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts)) +- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) @@ -1912,12 +2036,14 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) +- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) +- `@deepseek-ai/dsh-workspace` — requires `storage` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) ## Seam packages (not directly loadable) @@ -1942,16 +2068,13 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) -- `@deepseek-ai/dsh-client-modules` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-host-apiproxy` ([`packages/host/apiproxy/src/index.ts`](../packages/host/apiproxy/src/index.ts)) - `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts)) -- `@deepseek-ai/dsh-host-webserver` ([`packages/host/webserver/src/index.ts`](../packages/host/webserver/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ed9bebdc92..1948c17f97 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -489,6 +489,26 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:103`](../../packages/ui/commands/src/index.ts) +## `domain/*` + +### `domain/changed` — emit + +A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability. Events of one domain arrive in its write-chain order. + +```ts cordis-catalog +/** + * A domain record or the global singleton changed, emitted once per write + * strictly after the backend acknowledged durability. Events of one + * domain arrive in its write-chain order. + * @param change - domain, table (`''` for global), key (`''` for global), + * operation discriminant, and on `put` the new snapshot. + * @mode emit + */ +'domain/changed'(change: DomainChanged): void +``` + +Source: [`packages/storage/storage-domain/src/events.ts:46`](../../packages/storage/storage-domain/src/events.ts) + ## `fs/*` ### `fs/edit-intent` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d6cc97086..86fc08803c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -319,6 +319,50 @@ Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../c Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) +## `ctx.clientModuleHost` — `ClientModuleHostService` + +The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it). + +```ts cordis-catalog +/** + * Current composed entry graph (stable object between changes). + * @returns the graph served as `window.__DSH_BOOT__`. + */ +graph(): WebBootGraph + +/** + * Absolute path of an entry's client bundle. + * @param id - entry id (package name). + * @returns the path, or undefined for an unknown id. + */ +clientPath(id: string): string | undefined + +/** + * Re-hash one bundle (the HMR watch's registration hook — the only entry + * point through which bundle content changes reach the graph). + * @param id - entry id (package name). + * @returns the new rev, or undefined for an unknown id. + */ +rebuilt(id: string): string | undefined + +/** + * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev. + * @param listener - receives the entry id and its new bundle rev. + * @returns the unsubscriber. + */ +onRebuilt(listener: (id: string, rev: string) => void): () => void + +/** + * Fires after any flush that recomposed the graph (row added/removed, or a + * rebuilt rev change). Pull model: listeners re-read {@link graph}. + * @param listener - notified with no payload. + * @returns the unsubscriber. + */ +onGraphChanged(listener: () => void): () => void +``` + +Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts) + ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. @@ -614,6 +658,30 @@ Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-d Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts) +## `ctx.httpServer` — `HttpServerService` + +The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. + +```ts cordis-catalog +/** + * Register a named route. Duplicate (kind, path) throws — route patterns are + * a composition-level contract, so a collision is a misconfiguration. + * @param route - kind, path, and the owning handler. + * @returns the disposer removing the route. + */ +register(route: WebRoute): () => void + +/** + * Register an index.html transform, applied to every index response in + * registration order. + * @param transform - pure html-to-html function. + * @returns the disposer removing the transform. + */ +tapIndex(transform: (html: string) => string): () => void +``` + +Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) + ## `ctx.invariants` — `InvariantService` Package-owned invariant registry with global and regex-based selection. @@ -1353,6 +1421,30 @@ Types: [SaveTextSpill](../core-data-structures/spill.md) · [SpillRef](../core-d Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts) +## `ctx.storage` — `Storage` + +The storage hub service. Backends register under `backend`; data forms mount under their `StorageForms` key and are reached as `ctx.storage.
`. + +```ts cordis-catalog +/** + * Mount a data-form facility on the hub. Mounting is an effect: the + * returned disposer unmounts the form. + * @param form - Form key declared in {@link StorageForms}. + * @param facility - The facility instance to expose. + * @returns the disposer that unmounts the form. + */ +mount(form: K, facility: StorageForms[K]): () => void + +/** + * Resolve a mounted data form. + * @param form - Form key declared in {@link StorageForms}. + * @returns the mounted facility. + */ +form(form: K): StorageForms[K] +``` + +Source: [`packages/storage/storage/src/index.ts:35`](../../packages/storage/storage/src/index.ts) + ## `ctx.subagents` — `SubagentService` Named provider registry and capability-checked start surface. @@ -1813,6 +1905,52 @@ Types: [WorkflowRun](../core-data-structures/workflow.md) · [WorkflowStartReque Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +## `ctx.workspace` — `WorkspaceRegistry` + +The workspace registry service. Opens the `workspace` domain at startup, rebuilds one entity per stored record, and serves entities from an in-memory cache keyed by id. Session persistence is an OPTIONAL peer (resolved via `ctx.get`, never injected): while it is absent, session attachment rejects (what cannot be validated is not recorded) and `sessionIds` projections serve the account unfiltered. + +There is deliberately no delete entry point in this phase: workspace deletion ships as one complete semantic together with the session-cascade primitives (future work in the owning Agent Note). + +```ts cordis-catalog +/** + * Create a workspace over an existing directory. The path is canonicalized + * through `fs.realpath` first — a nonexistent path rejects with the + * original `ENOENT`, a path resolving to anything but a directory rejects, + * and a canonical path already owned by another workspace (including a + * symlink resolving to it) rejects. + * @param path - Directory the workspace points at; canonicalized before storing. + * @param title - Display title; defaults to `basename` of the canonical path. + * @returns the created workspace after durability. + */ +async create(path: string, title?: string): Promise + +/** + * Look up a workspace by id. + * @param id - The workspace id. + * @returns the workspace, or `undefined` when unknown. + */ +get(id: WorkspaceId): Workspace | undefined + +/** + * Snapshot of all workspaces, in load-then-creation order. + * @returns a fresh array of the cached entities. + */ +list(): Workspace[] + +/** + * Resolve a workspace by directory path, through the same `fs.realpath` + * canon as {@link create} (hence async). A path that does not exist rejects + * with the original error — a missing directory has no canonical form to + * compare (a workspace whose recorded directory vanished is only reachable + * by id; see `Workspace.status`). + * @param path - Directory path in any spelling (symlinks, `..`, trailing slash). + * @returns the owning workspace, or `undefined` when none matches. + */ +async resolveByPath(path: string): Promise +``` + +Source: [`packages/workspace/workspace/src/index.ts:60`](../../packages/workspace/workspace/src/index.ts) + ## Inherited `ctx` members (cordis core + loader/hmr/timer) The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b36d390f55..16de2600af 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -22,20 +22,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | +| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../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-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`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:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -60,7 +61,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `webserver` | +| `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `slots/changed` | `runtime` (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..1f359eef83 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -195,6 +195,12 @@ flowchart TD pkg_scripts["scripts"] pkg_telemetry["telemetry"] end + subgraph group_storage["packages/storage"] + pkg_storage["storage"] + pkg_storage_domain["storage-domain"] + pkg_storage_json["storage-json"] + pkg_storage_sqlite["storage-sqlite"] + end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] pkg_tool_tasks["tool-tasks"] @@ -205,6 +211,9 @@ flowchart TD pkg_workflow["workflow"] pkg_workflow_workerthread["workflow-workerthread"] end + subgraph group_workspace["packages/workspace"] + pkg_workspace["workspace"] + end pkg_brand --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants @@ -214,7 +223,6 @@ flowchart TD pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_connection --> pkg_invariants pkg_client_i18n --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants @@ -230,9 +238,13 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_runtime --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_storage --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules + pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -250,6 +262,12 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_storage_domain --> pkg_invariants + pkg_storage_domain --> pkg_storage + pkg_storage_json --> pkg_invariants + pkg_storage_json --> pkg_storage + pkg_storage_sqlite --> pkg_invariants + pkg_storage_sqlite --> pkg_storage pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -413,6 +431,12 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage + pkg_workspace --> pkg_storage_domain pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -789,7 +813,6 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-connection`](../packages/client/connection) | `client` | [`invariants`](../packages/support/invariants) | | [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | @@ -805,13 +828,18 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | -| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) | +| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -859,6 +887,7 @@ flowchart TD | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/knip.json b/knip.json index 59658e1df6..713e171a72 100644 --- a/knip.json +++ b/knip.json @@ -56,12 +56,8 @@ ] }, "packages/host/webserver": { - "entry": [ - "tests/**/*.spec.ts" - ], "project": [ - "src/**/*.ts", - "tests/**/*.ts" + "src/**/*.ts" ] }, "packages/host/runtime": { @@ -579,7 +575,8 @@ "src/**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/dsh-client-.+" + "@deepseek-ai/.+", + "@cordisjs/.+" ] }, "packages/client/modules": { diff --git a/missions/tasks/20260724-storage-workspace/dev-plan.md b/missions/tasks/20260724-storage-workspace/dev-plan.md new file mode 100644 index 0000000000..da621897ba --- /dev/null +++ b/missions/tasks/20260724-storage-workspace/dev-plan.md @@ -0,0 +1,179 @@ +# Storage + Workspace 工程开发文档 + +> 施工范围:5 个新包,session 侧零 diff。规范正典:[Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)——本文只写工程拆解(目录/文件、class 落位、teammate 分工、并行依赖),接口语义以 Note 为准,冲突时改这里不改 Note(除非经用户拍板)。 +> 门禁口径:GUI 免门禁期同款——不随手写测试门禁,跑 typecheck/build 保证编译;测试文件按仓库惯例落位(包级 `tests/`、`.spec.ts`),红绿在 PR 窗口收口。 + +## 0. 总览 + +``` +packages/storage/ + storage/ dsh-storage 枢纽:Storage service + BackendRegistry + StorageForms + storage-json/ dsh-storage-json JsonStorageBackend(kv facet) + storage-sqlite/ dsh-storage-sqlite SqliteStorageBackend(kv facet) + storage-domain/ dsh-storage-domain DomainFacility + Domain + KvTable + domain/changed +packages/workspace/ + workspace/ dsh-workspace WorkspaceRegistry + WorkspaceEntity + workspaceDomainSpec +``` + +依赖与并行关系(→ = 依赖): + +``` +W1 storage(枢纽) ──→ W2a storage-json ──┐ + └──→ W2b storage-sqlite ─┼──→ 集成冒烟(W4 兼) + └──→ W3 domain ──────────┘ + └──→ W4 workspace +``` + +- W1 先行(接口包是所有人的编译依赖),完成后 W2a/W2b/W3 **三线并行**;W4 依赖 W3 的接口定型(不必等 json/sqlite 完工,可对着 W3 的类型先写,用内存假 backend 跑测试)。 +- 每包的 package.json/tsconfig/README/invariant 伴生由该包 owner 自己配齐(模板照抄 `packages/session-persistence/session-persistence-sqlite/` 的形状)。 + +## 1. W1:`dsh-storage`(枢纽)——主线程自做 + +量小且是全组编译根,主线程直接写,不派 teammate。 + +``` +packages/storage/storage/ + package.json # 无运行时依赖;cordis peerDep + dev + tsconfig.json + src/index.ts # Storage service + apply + 全部导出 + src/registry.ts # BackendRegistry + src/backend.ts # StorageBackend/KvFacet/KvUnitDescriptor/KvUnit 类型 + src/error.ts # StorageError + code 联合 + src/invariant.ts # 见下 + tests/registry.spec.ts # registry/mount 套件 + README.md +``` + +class/接口逐条(签名以 Note 为准,此处列实现要点): + +| 成员 | 实现要点 | +| --- | --- | +| `class Storage extends Service` | `super(ctx, 'storage')`;`readonly backend = new BackendRegistry()`;`mount(form, facility)` 存入私有 `Map`,重复 → `StorageError('duplicate-mount')`,返回删除闭包;`get domain()` 从 map 取,缺 → `StorageError('form-not-mounted')` | +| `class BackendRegistry` | 私有 `Map`;`register` 重名 → `duplicate-backend`,返回 `() => map.delete(name)`;`get` 缺名 → `backend-not-found`;`names()` 返回数组拷贝 | +| `interface StorageForms {}` | 空接口 + JSDoc(merge-extensible,键 = 数据形式名) | +| `interface StorageBackend / KvFacet / KvUnitDescriptor / KvUnit` | 纯类型 + 契约 JSDoc(七条契约写在 KvUnit 各方法 JSDoc 上——这是 backend 实现者的规范文本) | +| `class StorageError extends Error` | `constructor(code, message?, cause?)`;`name = 'StorageError'` | +| `const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/` | 导出;descriptor 校验用(backend open 时验,fail loud) | +| invariant | 枢纽自身无运行时不变量(纯注册表,无事件流/可变盘面),写"explained empty"(措辞照抄 sqlite 后端 invariant.ts 的 "No runtime invariant:" 模板) | + +事件面:本包**无**事件(`domain/changed` 归 dsh-storage-domain)。 + +## 2. W2a:`dsh-storage-json` —— teammate **json-backend** + +``` +packages/storage/storage-json/ + src/index.ts # Config + apply + JsonStorageBackend + src/unit.ts # JsonKvUnit + src/atomic.ts # temp+fsync+rename 原子写(含 win32 分支) + src/format.ts # 文件格式 parse/serialize + malformed 检查 + src/invariant.ts + tests/json-backend.spec.ts # 挂共享契约套件(见 §5)+ json 特有(文件肉眼格式、malformed) +``` + +| class | 要点 | +| --- | --- | +| `Config` | schemastery,`root: z.string().required()`(JSDoc 说明为何无默认:防 cwd 散落,参照 session-persistence 措辞) | +| `class JsonStorageBackend implements StorageBackend` | `name='json'`;`kv = { open }`;持 `Map`(同名重复 open → 复用还是报错:**报错**,unit 生命周期归调用方,double-open 是 bug);`close()` 逐 unit close,幂等 | +| `class JsonKvUnit implements KvUnit` | 内存态 `{ version, global, tables: Map> }` 为权威;构造时读盘:文件缺失 = 空单元(不落盘),存在则 parse + 版本比对;每个写原语 = 改内存 → `writeAtomic(serialize())`;**写不排队**(契约第 4 条:串行是调用方的事),但单次 writeAtomic 内部完整(temp/fsync/rename);close 后操作 → `closed` | +| `atomic.ts` | `writeAtomic(path, data)`:同目录 temp 文件 + fsync + rename;win32 分支照抄 `session-persistence-jsonl/src/win32.ts` 的替换语义(先照抄,`log` facet 迁移期再提共享——Note 已记)| +| `format.ts` | `serialize(unit): string`(`JSON.stringify(…, null, 2)` + 尾换行);`parse(text): ParsedUnit`,缺 `unit` 头/结构不符 → `malformed-medium` | +| apply | `ctx.effect(() => { const d = ctx.storage.backend.register('json', backend); return async () => { d(); await backend.close() } })`;inject: `['storage']` | +| invariant | 断言候选:rename 发布后盘上文件必可 parse 回等价内存态(写后读回校验,仅测试态开启);若判断无运行时可断言关系则 explained empty | + +## 3. W2b:`dsh-storage-sqlite` —— teammate **sqlite-backend** + +``` +packages/storage/storage-sqlite/ + src/index.ts # Config + apply + SqliteStorageBackend + src/unit.ts # SqliteKvUnit + src/schema.ts # SCHEMA_VERSION + openDatabase + DDL + src/invariant.ts + tests/sqlite-backend.spec.ts +``` + +| class | 要点 | +| --- | --- | +| `Config` | `path: z.string().required()`(`:memory:` 允许)+ `journalMode` 枚举 default 'wal' | +| `schema.ts` | `STORAGE_SQLITE_SCHEMA_VERSION = 1`;`openDatabase(config)` 照抄 session-persistence-sqlite 的序列(mkdir 0o700 → wx 0o600 建文件 → PRAGMA foreign_keys → journal_mode → user_version 检查盖章/拒绝 → 建 `units`/`unit_globals`);**先照抄不提共享 helper**(Note 已记:提取放迁移期) | +| `class SqliteStorageBackend` | `name='sqlite'`;单 `DatabaseSync` 连接;`kv.open(descriptor)`:校验名字字符集 → `units` 行版本比对(无行则 INSERT 盖章)→ 按 descriptor.tables 逐张 `CREATE TABLE IF NOT EXISTS "u__
"` → 返回 unit;`close()` 关连接 | +| `class SqliteKvUnit` | 预编译语句(每表 upsert/delete/select-all + global upsert);`loadAll` 全表 SELECT 组装;`putRecord` = `INSERT … ON CONFLICT(key) DO UPDATE`;单语句原子,无显式事务;value `JSON.stringify`/parse | +| invariant | 断言候选:STRICT 表 + user_version 与常量一致(open 后检);或 explained empty | + +## 4. W3:`dsh-storage-domain` —— teammate **domain-layer** + +``` +packages/storage/storage-domain/ + src/index.ts # Config + apply + DomainFacility + src/spec.ts # DomainSpec/defineDomain/domainTable + descriptorOf + src/domain.ts # DomainImpl + KvTableImpl + 写链 + src/events.ts # domain/changed declaration merging + src/error.ts # DomainError + src/invariant.ts + tests/domain.spec.ts # 用内存假 backend(tests/helpers/memory-backend.ts) +``` + +| class | 要点 | +| --- | --- | +| `Config` | `backend: z.string().required()` + `routes: z.dict(z.string()).default({})` | +| `spec.ts` | `defineDomain` 恒等函数(编译期收窄)+ 名字/表名正则校验(违规 throw,misconfiguration fails loud);`descriptorOf(spec)` 投影 | +| `class DomainFacility` | 持 `Map`(already-open 检查);`open(spec)` 按 Note 六步实现;zod 依赖在此包(dependencies,不是 peer) | +| `class DomainImpl` | 写链 `chain: Promise`(`enqueue(job): Promise` 私有方法,所有写走它);内存态 `Map>` + global;每写:链上 → 改内存 → unit 原语 await → `ctx.emit('domain/changed', …)`;dispose:`enqueue(noop)` 排空 → `unit.close()` | +| `class KvTableImpl` | 读同步走内存;`update` fn 同步纯(类型上 `(current: V) => V`),缺 key → `missing-key`;`delete` 返回是否存在 | +| `events.ts` | 按 Note 全文(`@mode emit` + `@param`);`DomainChanged` 接口导出 | +| invariant | 断言候选(真不变量,建议做):**每次 `domain/changed` 事件的 value 必等于内存态当前值**(事件流 vs 可变数据的 owned relationship,正合仓库 invariant 规范)| +| tests/helpers/memory-backend.ts | `MemoryStorageBackend`:Map 实现 KvUnit,宣称版本可注入——共享给 W4 用 | + +## 5. 共享 backend 契约套件 —— domain-layer 兼写(或主线程) + +``` +packages/storage/storage/tests/contract.ts # export function runKvBackendContract(factory) +``` + +- 仿 `runPersistenceContract` 形状:`factory: () => Promise<{ backend, reopen(): Promise }>`,两后端 spec 文件各自 import 调用。 +- 覆盖 Note 七条契约 + 版本拒绝 + close 幂等;"崩溃再 open"用 `reopen()`(新实例指向同一介质)模拟。 +- 落在接口包 tests/ 下(不进 src,不发布),json/sqlite 的 devDependencies 指向 workspace 接口包即可复用。 + +## 6. W4:`dsh-workspace` —— teammate **workspace-domain** + +``` +packages/workspace/workspace/ + src/index.ts # apply + WorkspaceRegistry(service 挂 ctx.workspace) + src/types.ts # WorkspaceId brand + Workspace 接口 + src/spec.ts # workspaceRecord zod + workspaceDomainSpec + src/entity.ts # WorkspaceEntity(不出包:index.ts 不 re-export) + src/paths.ts # realpathNormalize(path) + src/invariant.ts + tests/workspace.spec.ts # MemoryStorageBackend + 假 sessionPersistence stub +``` + +(删除入口本期不存在:registry 无 delete、entity 无关联清理——整套删除语义在 Agent Note 的 future work 节。) + +| class | 要点 | +| --- | --- | +| `types.ts` | `WorkspaceId` brand + 工厂;`Workspace` 接口(Note 签名照录,JSDoc 齐全——这是对外契约) | +| `spec.ts` | `workspaceRecord`(path/title/sessionIds/createdAt/updatedAt)+ `workspaceDomainSpec = defineDomain({ name: 'workspace', version: 1, tables: { workspaces: … } })` | +| `paths.ts` | `realpathNormalize(p): Promise`——`fs.realpath`;ENOENT 原样抛(create 的 reject 路径) | +| `class WorkspaceRegistry extends Service` | `super(ctx, 'workspace')`;inject `['storage', 'sessionPersistence']`(sessionPersistence optional:`ctx.get()` 取,缺席时 attach 拒绝);`start()`:`ctx.storage.domain.open(workspaceDomainSpec)` + 重建 `Map`;`create`:realpath → resolveByPath 撞 → reject;否则 `WorkspaceId(randomUUID())` + `table.put` + 建实体入缓存;`list()` 快照数组(过滤无效 sessionId 的投影在实体 getter 做);**无 delete 方法**(future work,与 session 级联一体落地) | +| `class WorkspaceEntity implements Workspace` | 构造持 registry/id/record;getter 投影;`mutate(fn)` 私有:`table.update(id, r => stampUpdatedAt(fn(r)))` 后原地换 record;`attachSession`:读 `sessionPersistence.list()` 找 header(或 inspect),cwd realpath ≠ path → reject;幂等(已在账 → no-op);`detachSession` 摘账(不动 session 文件);`status()`:`fs.access(path)` | +| 一致性口径 | ①账指向的 session 查无:**投影过滤**(getter 层)+ 下次 mutate 摘除;③双重账 load 检出 → throw;④missing-dir 只反映在 status() | +| invariant | 断言候选:缓存实体集合与 domain 表 key 集合一致(owned relationship:registry 缓存 vs 权威盘面)| + +## 7. Teammate 编成与节奏 + +| teammate | 包 | 开工条件 | 预估节奏 | +| --- | --- | --- | --- | +| (主线程) | W1 storage 枢纽 + §5 契约套件骨架 | 立即 | 首批落盘,随后进入 review/dispatcher 角色 | +| json-backend | W2a | W1 类型可编译即开工 | 分批落盘:atomic/format 先行,unit 次之,契约套件接入收尾 | +| sqlite-backend | W2b | 同上 | schema.ts 先行(照抄源已指明),unit 次之 | +| domain-layer | W3 + memory-backend helper | 同上 | spec/error 先行 → DomainImpl 写链 → 事件 → 契约套件(若主线程未完成则兼) | +| workspace-domain | W4 | W3 的 src 类型定型(不等其测试) | types/spec/paths 先行 → registry/entity → 测试 | + +协作规矩(照 conventions):分批落盘每批几分钟内、每批一句话回执;产出零落盘超 5 分钟报告;不混 commit 别人的在途文件;代码注释一律英文且只写非显然契约;干完不 kill 保持待命。commit 纪律:`--no-verify`,按包分刀(W1 一刀 → W2a/W2b/W3 各一刀 → W4 一刀 → 测试/文档尾刀),文档(本文件 + Agent Note 增量)住顶刀。 + +## 8. 主线程验收清单(每包合入前) + +- [ ] `pnpm run typecheck` 过(本期唯一硬门禁) +- [ ] 包结构齐:package.json(`@deepseek-ai/dsh-*`、ESM、cordis peerDep)、README、invariant 伴生(真断言或 explained empty) +- [ ] 接口与 Agent Note 一致;发现实现逼着改接口 → 停下来报主线程裁决(不擅改 Note) +- [ ] 测试文件落位正确(包级 tests/、`.spec.ts`),能跑多少跑多少,红的记台账不追修 +- [ ] session-persistence 包零 diff(`git status` 检查线) diff --git a/packages/README.md b/packages/README.md index 4e18d7b5f8..f11f886eb2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -34,6 +34,8 @@ 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 | +| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | +| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 778b431b71..c26cafb143 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -43,10 +43,12 @@ "src" ], "peerDependencies": { + "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts new file mode 100644 index 0000000000..30e91522a2 --- /dev/null +++ b/packages/client/connection/src/api-path.ts @@ -0,0 +1,8 @@ +/** + * The /api URL prefix — single source for both halves of the web transport. + * The node half registers this prefix on the web server; browser-side path + * literals currently live in the apiproxy client layer (out of scope here). + */ + +/** Route prefix owning every api request (`/api` and `/api/`). */ +export const API_PATH = '/api' diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts new file mode 100644 index 0000000000..319d3e0b0b --- /dev/null +++ b/packages/client/connection/src/http-bridge.ts @@ -0,0 +1,59 @@ +/** + * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the + * web carrier; the fetch-shaped handler itself is transport-agnostic). + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' + +/** + * Bridge one node:http request to the fetch-shaped handler (client close + * aborts; SSE bodies stream out chunk by chunk). + * @param req - incoming node:http request (fully read before dispatch). + * @param res - node:http response the bridge writes and owns to completion. + * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + */ +export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { + const abort = new AbortController() + // Client-disconnect detection MUST hang off the response, not the request: + // since Node 16, IncomingMessage 'close' fires as soon as the request body is + // fully consumed (immediately for a bodyless GET), which would abort every SSE + // stream right after open. ServerResponse 'close' fires on connection teardown; + // writableEnded distinguishes a normal end() from the client going away. + res.on('close', () => { + if (!res.writableEnded) abort.abort() + }) + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server + requests; the fields are only optional on the client-side IncomingMessage type */ + const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { + method: req.method ?? 'GET', + headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + const response = await apiHandler.fetch(request) + res.writeHead(response.status, Object.fromEntries(response.headers.entries())) + if (response.body === null) { + res.end() + return + } + for await (const chunk of response.body) { + // Backpressure: a false return means the socket buffer is full — wait for drain + // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also + // resolves so a mid-wait disconnect can't park this loop forever; the close + // handler above aborts the handler stream, which then ends the iteration. + if (!res.write(chunk)) { + await new Promise((resolve) => { + const done = (): void => { + res.off('drain', done) + res.off('close', done) + resolve() + } + res.once('drain', done) + res.once('close', done) + }) + } + } + res.end() +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 313db07225..61d718b618 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,10 +1,36 @@ /** - * Connection plugin, node half. The package IS a dshClient plugin: the wire - * consumer layer lives in its client half in full (src/client/ — contract: - * api-contracts v3 section 3, inventory §3.2); consumers import the /client - * subpath. The empty apply exists so the plugin appears in the host Loader - * (lifecycle governance + dshClient discovery). + * Connection plugin, node half: the host end of the web transport. Registers + * the /api prefix route on the web server and bridges node:http requests to + * the transport-agnostic fetch-shaped api handler. The wire consumer layer + * lives in the client half (src/client/ — contract: api-contracts v3 + * section 3); consumers import the /client subpath. */ +import type { Context } from 'cordis' +// Type-only route import; it also carries the httpServer Context merge. +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { API_PATH } from './api-path.ts' +import { bridge } from './http-bridge.ts' -/** Host plugin body — no host-side behavior for the connection plugin. */ -export function apply(_ctx: unknown): void {} +export { API_PATH } from './api-path.ts' + +/** Cordis plugin name. */ +export const name = 'client-connection' + +/** Required services: the route registry and the api gateway. */ +export const inject = ['httpServer', 'apiProxy'] + +/** + * Mount the /api transport: wrap the api gateway into a fetch handler and + * serve it under the /api prefix. + * @param ctx - host plugin context carrying httpServer and apiProxy. + */ +export function apply(ctx: Context): void { + const apiHandler = toFetchHandler(ctx.apiProxy) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: (req, res) => bridge(req, res, apiHandler), + } + ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') +} diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index df16e00fd4..1112a4e638 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -15,10 +15,11 @@ export const name = 'client-connection-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the pure wire layer emits no cordis events and owns no + * No runtime invariant: the wire layer emits no cordis events and owns no * mutable cross-plugin relation — stream/reconnect sequencing is exercised - * directly by its behavior specs, and rpcId round-trip discipline is owned by - * the apiproxy contract layer. + * directly by its behavior specs, rpcId round-trip discipline is owned by the + * apiproxy contract layer, and the node half's single route registration's + * register/dispose symmetry is audited by the webserver package's invariant. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index efba1b0445..e9e880cfb4 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,10 +1,33 @@ -/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */ +/** Node half: registers the /api prefix route bridging to the api gateway. */ +import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import { apply } from '../src/index.ts' +import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { API_PATH, apply, inject } from '../src/index.ts' -describe('node half', () => { - it('apply is a no-op host placeholder', () => { - apply(undefined) - expect(true).toBe(true) // reaching here without throw is the contract +describe('connection node half', () => { + it('registers the /api prefix route and removes it with the fiber', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + // Structural fake: the plugin only touches register(); the service class + // carries private state a literal cannot (and need not) reproduce. + const httpServer: Pick = { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } + ctx.provide('httpServer', httpServer as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + + await fiber.dispose() + expect(routes).toHaveLength(0) }) }) diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 8b0357cf97..97d020dc53 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": ["node"] }, "include": [ "src" @@ -20,6 +21,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../host/webserver" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index f6fcd44dba..fc262bb086 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -2,7 +2,7 @@ Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert. -The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `