Merge remote-tracking branch 'origin/master' into codex/trim-redundant-comments
# Conflicts: # packages/client/connection/src/index.ts # packages/host/webserver/tests/web-plugins.spec.ts
This commit is contained in:
+2
-2
@@ -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: 58651fd258a6b2929c58bb6f93b44adb6e8e1818
|
||||
2026-07-23-client-plugin-loading-model.zh.md: f60b06c7bfaa9c70170082ac4384ba2bd899676e
|
||||
2026-07-23-client-plugin-loading-model.md: 9f8b69739213b9bdc52e4b4de4d663419e596c66
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 05a78fbba9859378178720f012af462382b3ab0f
|
||||
@@ -56,11 +56,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
**Host side — compose the graph.**
|
||||
|
||||
1. The composing app (`apps/cli`) mounts the roster as in-memory Loader entries via `mountWebPlugins`. The roster is one flat list of the plugin packages, plus the `client-hmr` row under `--dev`. A roster package that fails to import throws loud at mount.
|
||||
2. The registry (`createHostWebPluginRegistry`) scans the mounted entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — load-time fail loud.
|
||||
3. The registry rescans on cordis `internal/plugin`, microtask-debounced; a rescan failure keeps serving the previous graph. Each bundle's content is hashed into its `rev` (cache busting + HMR diff anchor), and the row set into `graph.rev`. Every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are a wire contract dual-held on both sides, because the webserver keeps zero workspace dependencies.
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
|
||||
|
||||
Why is the roster a hand-written list and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call. The roster lives in `apps/cli/web.ts` rather than cordis.yml only because `dsh web`'s host is a hand-assembled `bootHost` with no Loader config tree yet.
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
|
||||
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch is fetch + execute, which registers factories only. A single row's prefetch failure is swallowed here: phase two's import retries the fetch and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
|
||||
|
||||
@@ -74,9 +74,9 @@ Why is the roster a hand-written list and not a scan? Because which plugins comp
|
||||
|
||||
### Hot reload: one driver plugin, self-watched bundles
|
||||
|
||||
Whether hot reload is active is a composition decision: dev graphs include the `client-hmr` row (a normal plugin package) and turn on bundle watching; prod graphs do neither.
|
||||
Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
|
||||
|
||||
How does a rebuilt bundle become a reload signal? The webserver observes it itself — no builder tells it. The registry scan already holds every plugin's bundle path (`clientPath`), so in dev mode the registry stat-polls each scanned bundle file with `fs.watchFile`. 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. Watch set membership follows the table: rescans add watches for new rows and drop them for vanished ones, dispose drops all. 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 the graph's bundle paths from `ctx.clientModuleHost.clientPath(id)` and stat-polls each with `fs.watchFile`, following graph membership through `onGraphChanged` (rows added late in the boot window get watches; vanished rows drop them; all lifecycles ride `ctx.effect`). 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 it calls `clientModuleHost.rebuilt(id)` — the single re-hash entry point — and when the `rev` actually changed, 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. 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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -56,11 +56,11 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
|
||||
|
||||
**host 侧——组合这张图。**
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)经 `mountWebPlugins` 把名册挂载为内存中的 Loader entry。名册是插件包的一张平铺清单,`--dev` 下外加 `client-hmr` 行。名册里 import 失败的包在挂载时大声抛错。
|
||||
2. 注册表(`createHostWebPluginRegistry`)扫描已挂载 entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——装载期大声失败。
|
||||
3. 注册表在 cordis `internal/plugin` 上重扫,微任务去抖;重扫失败则继续供给上一张图。每个 bundle 的内容哈希进其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`。每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图的类型是两侧各持一份的 wire 契约,因为 webserver 保持零 workspace 依赖。
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
|
||||
|
||||
为什么名册是手写清单而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定。名册住在 `apps/cli/web.ts` 而非 cordis.yml,只是因为 `dsh web` 的 host 还是一个手工装配的 `bootHost`,没有 Loader 配置树。
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
**第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即 fetch + 执行,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试 fetch 并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。
|
||||
|
||||
@@ -74,9 +74,9 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
|
||||
|
||||
### 热重载:一个驱动插件,自行监视的 bundle
|
||||
|
||||
热重载是否启用是一项组合决策:dev 图包含 `client-hmr` 行(一个常规的插件包)并开启 bundle 监视;prod 图两者皆无。
|
||||
热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE 通道;prod 组合不挂载,两者皆无。
|
||||
|
||||
重建好的 bundle 怎么变成重载信号?webserver 自己观察——没有构建器来通知它。注册表扫描本就握有每个插件的 bundle 路径(`clientPath`),因此 dev 模式下注册表用 `fs.watchFile` 对每个已扫描的 bundle 文件做 stat 轮询。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,注册表就重哈希该行(`rebuilt(id)`);当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE(Server-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。监视集合的成员随表走:重扫为新行添加监视、为消失的行撤下监视,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 路径并用 `fs.watchFile` 逐一 stat 轮询,监视集合的成员随 `onGraphChanged` 走(boot 窗口内晚到的行补上监视、消失的行撤下监视,生命周期全部收 `ctx.effect`)。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,它调用 `clientModuleHost.rebuilt(id)`——重哈希的唯一入口;当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE(Server-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询间隔是一个经校验的配置字段(默认 500ms),不是常量。重建 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
|
||||
|
||||
|
||||
+6
@@ -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
|
||||
+42
@@ -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 |
|
||||
+42
@@ -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 |
|
||||
+6
@@ -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
|
||||
@@ -0,0 +1,329 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-24-domain-kv-storage-and-workspace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The host's only persistence surface is the session event log (`packages/session-persistence`: append-only, one file per session). Anything that does not belong to a single session has nowhere to live, and two real needs exist today:
|
||||
|
||||
- **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned).
|
||||
- **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates.
|
||||
|
||||
Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code.
|
||||
|
||||
## Proposal
|
||||
|
||||
Create the `packages/storage/` group — the `ctx.storage` hub (backend registry + data-form mounts), two backends, the domain data form — plus the workspace consumer package; extend `SessionPersistence` with a delete primitive.
|
||||
|
||||
| Package | Path | ctx surface | This phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage` (the hub) | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | registers backend `json` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | registers backend `sqlite` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | mounts `ctx.storage.domain` | ✓ |
|
||||
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
|
||||
| `SessionPersistence.delete` extension + cascade orchestration | `packages/session-persistence/*` | new method on the existing seam | ✗ future work (session side untouched this phase) |
|
||||
| `workspace.*` / `session.delete` RPC, GUI wiring, boot assembly | — | — | ✗ next phase |
|
||||
|
||||
(workspace lives in its own group rather than `packages/host/`: the host group's naming rule requires the `dsh-host-*` prefix while this package is named `dsh-workspace`; and the workspace entity is a domain concept, not bound to the host assembly tier. Unrelated to the existing `workspace-context` package — that is an AGENTS.md instruction loader.)
|
||||
|
||||
Dependency direction: `dsh-workspace` → `dsh-domain` → `dsh-storage` ← the two backends. `dsh-workspace` additionally depends on the read-only face of `ctx.sessionPersistence` (attach's cwd check reads the session header; when the service is absent, attach rejects outright — no verification, no bookkeeping). The `ctx.sessions` running-check for session deletion moves into future work together with the cascade.
|
||||
|
||||
### `dsh-storage`: the storage hub
|
||||
|
||||
A pure registration hub, no IO of its own, no Config. The `Storage` service mounts at `ctx.storage` with two faces: `backend` (a `BackendRegistry`: `register(name, backend)` returns the disposer, duplicate names throw; `get(name)` throws `backend-not-found` for unknown names) and data-form mounting (`mount(form, facility)` over the merge-extensible `StorageForms` map, into which `dsh-domain` merges the `domain` key; unmounted access throws `form-not-mounted`). The signature text lives in `packages/storage/storage/src/index.ts` and `src/registry.ts`.
|
||||
|
||||
**Multiple backends stay mounted side by side**; which backend serves a domain is `dsh-domain`'s configuration (below), never a global either-or. Disposer semantics = remove the name from the table; closing the backend itself belongs to the backend package's effect closure, unregister first then close.
|
||||
|
||||
A backend is one **medium owner** (a file-tree root / one db file) exposing primitives through **data-shape facets** — only `kv` this phase; the session migration adds `log` (see the migration section). A facet is an optional member: absence means the backend cannot serve that shape, and resolution fails loud. The `kv` facet's primitive surface: `open(descriptor)` (descriptor = name/version/table list/global flag, with names and table names restricted to `^[a-z][a-z0-9_]*$` doubling as file-name and SQL-identifier segments) returns a unit exposing `loadAll` / `putRecord` / `deleteRecord` (missing key is a no-op) / `setGlobal` / `close` (idempotent); values are opaque JSON to the backend. The normative text (with per-method JSDoc) is `packages/storage/storage/src/backend.ts`.
|
||||
|
||||
The backend contract (asserted clause by clause by the shared conformance suite, one suite for both backends):
|
||||
|
||||
1. `open` creates when the medium holds nothing (lazy materialization allowed: may defer to the first write, but `loadAll` must immediately serve empty tables); loads when the medium exists.
|
||||
2. A stored version ≠ descriptor.version → `StorageError('version-mismatch')`; no migration, no rebuild.
|
||||
3. Durability: after a write primitive resolves, a process crash followed by a re-open must observe the write in `loadAll`.
|
||||
4. The backend does not promise write ordering within a unit — **the caller serializes**; the backend only guarantees each single call is atomic (JSON whole-file replace / SQLite single statement).
|
||||
5. `deleteRecord` is idempotent; `putRecord` overwrites.
|
||||
6. Any string key / any JSON value is safe (keys never reach file paths, a structural property).
|
||||
7. `close` is idempotent; any operation after close → `StorageError('closed')`.
|
||||
|
||||
The error vocabulary is `StorageError` with a code discriminant: `backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed` (`packages/storage/storage/src/error.ts`).
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
Config is `root` only (required, no default, schemastery); apply registers backend `json` inside `ctx.effect()`, and the disposer unregisters the name before `backend.close()`.
|
||||
|
||||
- Layout `<root>/<unitName>.json`, one file per unit; directory 0o700, files 0o600.
|
||||
- File format (version stamp in the header; the file is always the current net state, `JSON.stringify(…, null, 2)` human-readable — that legibility is this backend's reason to exist):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- Writes: every write primitive = full serialization of the in-memory state → temp write + fsync → atomic rename publish (the Windows variant follows session-persistence-jsonl's win32 path). Memory is authoritative, disk is its projection.
|
||||
- `loadAll`: parse the whole file at open; a missing `unit` header, non-object tables, etc. → `malformed-medium`. A missing file = an empty unit, materialized on first write.
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
Config is `path` (required, `':memory:'` allowed) plus `journalMode` (enum, default `wal`); apply mirrors json, registering backend `sqlite`.
|
||||
|
||||
- `node:sqlite` `DatabaseSync`; the open sequence follows session-persistence-sqlite: mkdir 0o700 → `open(path,'wx',0o600)` exclusive create when missing → `PRAGMA foreign_keys=ON` → journal_mode → version check → create tables.
|
||||
- Physical layout version `STORAGE_SQLITE_SCHEMA_VERSION = 1` in `PRAGMA user_version`: 0 → stamp; ≠ → `version-mismatch`.
|
||||
- DDL (all STRICT; table names concatenated from the restricted character set with the `u_` prefix, no external input ever reaches DDL):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS unit_globals (
|
||||
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
|
||||
-- 每 unit 每表:
|
||||
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
|
||||
```
|
||||
|
||||
- Unit versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
|
||||
- Write primitives are single statements and thus atomic; no cross-statement transactions needed (the domain layer has no cross-table transactions, see the out-of-scope list).
|
||||
|
||||
### `dsh-domain`: the domain data form
|
||||
|
||||
A single implementation, not abstracted; consumers depend on this layer only and never touch backends directly.
|
||||
|
||||
```ts ignore-check
|
||||
export const Config = z.object({
|
||||
backend: z.string().required(), // 默认后端名,必填
|
||||
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
||||
}
|
||||
```
|
||||
|
||||
(Facility unmount order: dispose each domain first (drain its write chain), then remove the name from the hub — in-flight writes still emit `domain/changed` during the drain, and the event-consistency invariant resolves domains back through the facility, so the name must stay resolvable at that point.)
|
||||
|
||||
Domain declarations (the spec object is defined and exported by the package that owns the domain — the single source of type and runtime truth; schemas use zod with `z.infer` deriving the types without re-declaration — the record model projects into RPC wire schemas next phase and the wire boundary is all zod; schemastery still owns plugin Config only):
|
||||
|
||||
```ts ignore-check
|
||||
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
|
||||
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
|
||||
|
||||
export interface DomainSpec {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$
|
||||
readonly version: number
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
readonly tables: Record<string, DomainTableSpec<string, unknown>>
|
||||
}
|
||||
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
|
||||
```
|
||||
|
||||
`DomainFacility.open(spec)` exact semantics (sequential; any failing step fails the whole open):
|
||||
|
||||
1. A domain with this name already open → `DomainError('already-open')`.
|
||||
2. Backend name = `config.routes[spec.name] ?? config.backend`; `ctx.storage.backend.get(name)` (an unmounted name propagates `backend-not-found` — misconfiguration fails loud).
|
||||
3. Backend lacks the `kv` facet → `DomainError('facet-unsupported')`.
|
||||
4. `kv.open(descriptorOf(spec))` (the descriptor is a direct projection of the spec).
|
||||
5. `loadAll()`; every record passes `valueSchema.parse`, the global passes its schema (null takes `initial`, not persisted — first write materializes). A failure → `DomainError('invalid-record', { table, key })` (the durable boundary must validate; the write side does not re-validate).
|
||||
6. Construct the `Domain` and register `ctx.effect()`: the disposer drains the write chain → `unit.close()`.
|
||||
|
||||
```ts ignore-check
|
||||
export interface Domain</* 由 spec 推导 */> {
|
||||
readonly name: string
|
||||
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
|
||||
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
|
||||
}
|
||||
|
||||
export interface KvTable<K extends string, V> {
|
||||
get(key: K): V | undefined // 内存快照,同步
|
||||
entries(): IterableIterator<[K, V]>
|
||||
keys(): IterableIterator<K>
|
||||
readonly size: number
|
||||
put(key: K, value: V): Promise<void>
|
||||
delete(key: K): Promise<boolean> // false = 本就不存在
|
||||
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
|
||||
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Single-level mapping**: key → record, no nested tables; hierarchical needs use composite keys or fields inside the value. The two backends stay isomorphic as a result (one JSON object level ↔ one SQLite row).
|
||||
- **Records are plain data**: immutable, directly JSON-serializable POJOs; values returned by `get`/`entries` must not be mutated in place (TypeScript readonly projection, no runtime freezing). Behavior-carrying domain objects belong to consumer packages.
|
||||
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
|
||||
- **Version fails loud**: a stored version differing from the spec throws outright; no migration, no rebuild (the data is not regenerable; pre-release rejects old formats).
|
||||
- **Change events**: after each write's durability resolves, emit `domain/changed` (`@mode emit`), one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); the payload `DomainChanged` is a put/deleted discriminated union — domain + table + key (both `''` for global changes) + operation, with the put branch carrying the new snapshot value and the deleted branch carrying none (`packages/storage/storage-domain/src/events.ts`). This is next phase's RPC push-frame event source. The error vocabulary is `DomainError`, codes: `already-open` / `facet-unsupported` / `invalid-record` (with `{ table, key }`) / `missing-key` / `closed`.
|
||||
|
||||
### Future work: session-side deletion (design settled, not implemented this phase)
|
||||
|
||||
This section is the settled construction spec; the implementation phase changes code only, not semantics. No session-persistence file is modified this phase.
|
||||
|
||||
```ts ignore-check
|
||||
export abstract class SessionPersistence extends Service {
|
||||
/**
|
||||
* Permanently delete one session's stored log.
|
||||
* Queued on the per-id write chain (serialized with in-flight appends).
|
||||
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
|
||||
* After deletion the id behaves as unknown for every subsequent operation.
|
||||
*/
|
||||
abstract delete(id: SessionId): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- JSONL backend: unlink the session's file (including the `.zstd` variant); neither file nor intent → reject.
|
||||
- SQLite backend: one transaction `DELETE FROM events…; DELETE FROM sessions…`; zero rows hit and no intent → reject.
|
||||
- After a successful delete, emit `'session-persistence/deleted'(id: SessionId)` (`@mode emit`; the session-persistence event surface, unrelated to `domain/changed`). Derived data (the session-query full-text index and the like) subscribes and cleans itself; the persistence layer never reaches into indexes, and the crash window is covered by derived indexes being droppable-and-rebuildable.
|
||||
|
||||
Orchestration rules (implemented together with the cascade; the `session.delete` RPC and the workspace cascade reuse the same rules):
|
||||
|
||||
| Check (in order) | On failure |
|
||||
| --- | --- |
|
||||
| No target (the whole subtree when recursive) is running in `ctx.sessions` | throw, delete nothing; callers cancel first then delete — the persistence layer never reaches back into the runtime |
|
||||
| Non-recursive: the target has no descendants (descendants = the `parentSessionId` transitive closure, derived from `list()` headers) | throw: by default only leaves are deletable; `recursive: true` opts into recursion |
|
||||
| Recursive order is bottom-up (leaves → root) | — a mid-way crash leaves only "half the subtree deleted, ancestors intact"; re-running the same delete converges, and no dangling parent exists at any moment |
|
||||
| Some id in the cascade is already gone from disk | skip (idempotent resumption); any other error aborts |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
The package owns the `WorkspaceId` brand and exposes `ctx.workspace`. The record key is a generated uuid — path is not the key: normalization rewrites it, and reference anchors must be stable.
|
||||
|
||||
```ts ignore-check
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
export function WorkspaceId(id: string): WorkspaceId
|
||||
|
||||
const workspaceRecord = z.object({
|
||||
path: z.string(), // realpath,见下
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(), // ISO
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace', version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: WorkspaceId
|
||||
readonly path: string
|
||||
readonly title: string
|
||||
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
|
||||
setTitle(title: string): Promise<void>
|
||||
/** Record a session under this workspace (idempotent). Rejects when the session
|
||||
* header's cwd (realpath) differs from this workspace's path. */
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
/** Live directory check, uncached. */
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'workspace')
|
||||
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
|
||||
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
list(): Workspace[]
|
||||
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
|
||||
// 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.
|
||||
@@ -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()`。
|
||||
|
||||
- 布局 `<root>/<unitName>.json`,一 unit 一文件;目录 0o700、文件 0o600。
|
||||
- 文件格式(版本戳在头,文件即当前净值,`JSON.stringify(…, null, 2)` 肉眼可读——这是该后端的存在理由):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- 写入:任何一次写原语 = 内存态全量序列化 → temp 写 + fsync → rename 原子发布(Windows 变体照抄 session-persistence-jsonl 的 win32 路径)。内存态是权威,盘是投影。
|
||||
- `loadAll`:open 时整文件 parse;缺 `unit` 头、tables 非对象等 → `malformed-medium`。文件不存在 = 空单元,首写才落盘。
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
Config 为 `path`(必填,`':memory:'` 允许)+ `journalMode`(枚举,默认 `wal`);apply 同 json,注册后端 `sqlite`。
|
||||
|
||||
- `node:sqlite` `DatabaseSync`;打开序列照抄 session-persistence-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_<unit>_<table>" (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
|
||||
```
|
||||
|
||||
- unit 版本存 `units` 行,descriptor 不符 → `version-mismatch`。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
|
||||
- 写原语单语句即原子,无跨语句事务需求(domain 层无跨表事务,见不做清单)。
|
||||
|
||||
### `dsh-domain`:领域数据形式
|
||||
|
||||
单实现不抽象;消费者只依赖这层,不直接触后端。
|
||||
|
||||
```ts ignore-check
|
||||
export const Config = z.object({
|
||||
backend: z.string().required(), // 默认后端名,必填
|
||||
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
||||
}
|
||||
```
|
||||
|
||||
(facility 卸载顺序:先 dispose 各域(排空写链)再从枢纽摘名——排空期间在途写仍发 `domain/changed`,事件一致性 invariant 经 facility 反查域,要求此时域名仍可解析。)
|
||||
|
||||
域声明(spec 对象由拥有该域的包定义导出,是类型与运行时的单一来源;schema 用 zod,`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schema,wire 边界全是 zod;schemastery 仍只管插件 Config):
|
||||
|
||||
```ts ignore-check
|
||||
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
|
||||
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
|
||||
|
||||
export interface DomainSpec {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$
|
||||
readonly version: number
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
readonly tables: Record<string, DomainTableSpec<string, unknown>>
|
||||
}
|
||||
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
|
||||
```
|
||||
|
||||
`DomainFacility.open(spec)` 精确语义(顺序执行,任一步失败即整体失败):
|
||||
|
||||
1. 同名域已打开 → `DomainError('already-open')`。
|
||||
2. 后端名 = `config.routes[spec.name] ?? config.backend`;`ctx.storage.backend.get(name)`(未挂载穿透 `backend-not-found`——misconfiguration fails loud)。
|
||||
3. 后端无 `kv` facet → `DomainError('facet-unsupported')`。
|
||||
4. `kv.open(descriptorOf(spec))`(descriptor 由 spec 直接投影)。
|
||||
5. `loadAll()`;每条记录 `valueSchema.parse`,global 过 schema(null 取 `initial`,不落盘,首写才落盘)。失败 → `DomainError('invalid-record', { table, key })`(durable 边界必须校验;写侧不重复校验)。
|
||||
6. 构造 `Domain` 并注册 `ctx.effect()`:disposer 排空写链 → `unit.close()`。
|
||||
|
||||
```ts ignore-check
|
||||
export interface Domain</* 由 spec 推导 */> {
|
||||
readonly name: string
|
||||
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
|
||||
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
|
||||
}
|
||||
|
||||
export interface KvTable<K extends string, V> {
|
||||
get(key: K): V | undefined // 内存快照,同步
|
||||
entries(): IterableIterator<[K, V]>
|
||||
keys(): IterableIterator<K>
|
||||
readonly size: number
|
||||
put(key: K, value: V): Promise<void>
|
||||
delete(key: K): Promise<boolean> // false = 本就不存在
|
||||
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
|
||||
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **一级 mapping**:key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构(JSON object 一层 ↔ SQLite 一行)。
|
||||
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费者包。
|
||||
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
|
||||
- **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。
|
||||
- **变更事件**:每次写落盘 resolve 后 emit `domain/changed`(`@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + 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<void>
|
||||
}
|
||||
```
|
||||
|
||||
- JSONL 后端:unlink 该 session 文件(含 `.zstd` 变体);文件与 intent 均无 → reject。
|
||||
- SQLite 后端:单事务 `DELETE FROM events…; DELETE FROM sessions…`;0 行命中且无 intent → reject。
|
||||
- 删除成功后 emit `'session-persistence/deleted'(id: SessionId)`(`@mode emit`;session-persistence 层事件面,与 `domain/changed` 无关)。派生数据(session-query 全文索引等)订阅自清;持久层不直连索引,崩溃窗口靠派生索引可丢弃重建兜底。
|
||||
|
||||
编排层规则(随级联删一起实施;`session.delete` RPC 与 workspace 级联复用同一规则):
|
||||
|
||||
| 检查(按序) | 不满足时 |
|
||||
| --- | --- |
|
||||
| 目标(递归时含整棵子树)无一在 `ctx.sessions` 运行 | throw,什么都不删;调用方先 cancel 再删,持久层不反向牵动运行时 |
|
||||
| 非递归时目标无后代(后代 = `parentSessionId` 传递闭包,由 `list()` header 求得) | throw:默认只能删叶子,`recursive: true` 显式递归 |
|
||||
| 递归序自底向上(叶→根) | ——中途崩溃只留"子树删一半、祖先在",重跑收敛,任何时刻无悬空 parent |
|
||||
| 级联中某 id 已不在盘上 | 跳过(幂等续删);其余错误中止 |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
包拥有 `WorkspaceId` brand,暴露 `ctx.workspace`。记录 key 为生成的 uuid——path 不做 key:规范化会改写它,引用锚点必须稳定。
|
||||
|
||||
```ts ignore-check
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
export function WorkspaceId(id: string): WorkspaceId
|
||||
|
||||
const workspaceRecord = z.object({
|
||||
path: z.string(), // realpath,见下
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(), // ISO
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace', version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: WorkspaceId
|
||||
readonly path: string
|
||||
readonly title: string
|
||||
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
|
||||
setTitle(title: string): Promise<void>
|
||||
/** Record a session under this workspace (idempotent). Rejects when the session
|
||||
* header's cwd (realpath) differs from this workspace's path. */
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
/** Live directory check, uncached. */
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'workspace')
|
||||
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
|
||||
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
list(): Workspace[]
|
||||
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
|
||||
// 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 层。
|
||||
@@ -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/<id>/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'
|
||||
+44
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string, Record<string, unknown>>()
|
||||
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<string, unknown>, ...bag } }
|
||||
})
|
||||
}
|
||||
|
||||
/** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
|
||||
private async bootTree(): Promise<void> {
|
||||
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<string, { config?: unknown }> {
|
||||
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<string, { config?: unknown }>()
|
||||
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<string, unknown> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
/** 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-127
@@ -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<void> {
|
||||
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/<id>/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<void> {
|
||||
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<ReturnType<typeof startWebServer>>
|
||||
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) })
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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<HTMLElement>('#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 })
|
||||
|
||||
@@ -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>): 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<string, string>) {
|
||||
return {
|
||||
graph: () => graph,
|
||||
clientPath: (id: string) => byId.get(id),
|
||||
onRebuilt: () => () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('web boot chain (keyless, real carrier)', () => {
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
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<ReturnType<typeof startWebServer>>
|
||||
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 <pkg> 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<string> => (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<void> => {
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<number> {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const probe = createServer()
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
{
|
||||
"path": "../../packages/client/web"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/modules"
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<br/>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<br/>Workspace entity registry"]
|
||||
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
|
||||
pkg_session_reference["session-reference"]
|
||||
svc_sessionReferences["ctx.sessionReferences<br/>Cross-session snapshot preparation"]
|
||||
@@ -118,6 +125,12 @@ flowchart LR
|
||||
svc_spillStore["ctx.spillStore<br/>Spill storage seam"]
|
||||
pkg_spill_local["spill-local"]
|
||||
pkg_spill_policy["spill-policy"]
|
||||
pkg_webserver["webserver"]
|
||||
svc_httpServer["ctx.httpServer<br/>HTTP route registration"]
|
||||
pkg_connection["connection"]
|
||||
pkg_modules["modules"]
|
||||
pkg_hmr["hmr"]
|
||||
svc_clientModuleHost["ctx.clientModuleHost<br/>Client plugin graph host"]
|
||||
pkg_workflow["workflow"]
|
||||
svc_workflows["ctx.workflows<br/>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.
|
||||
+128
-5
@@ -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:30`](../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<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
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 `<unit>.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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<form>`.
|
||||
|
||||
```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<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void
|
||||
|
||||
/**
|
||||
* Resolve a mounted data form.
|
||||
* @param form - Form key declared in {@link StorageForms}.
|
||||
* @returns the mounted facility.
|
||||
*/
|
||||
form<K extends keyof StorageForms>(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<Workspace>
|
||||
|
||||
/**
|
||||
* 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<Workspace | undefined>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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`) | - |
|
||||
|
||||
|
||||
+32
-3
@@ -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) |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<keyof StorageForms, unknown>`,重复 → `StorageError('duplicate-mount')`,返回删除闭包;`get domain()` 从 map 取,缺 → `StorageError('form-not-mounted')` |
|
||||
| `class BackendRegistry` | 私有 `Map<string, StorageBackend>`;`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<unitName, JsonKvUnit>`(同名重复 open → 复用还是报错:**报错**,unit 生命周期归调用方,double-open 是 bug);`close()` 逐 unit close,幂等 |
|
||||
| `class JsonKvUnit implements KvUnit` | 内存态 `{ version, global, tables: Map<string, Map<string, unknown>> }` 为权威;构造时读盘:文件缺失 = 空单元(不落盘),存在则 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>_<table>"` → 返回 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<domainName, DomainImpl>`(already-open 检查);`open(spec)` 按 Note 六步实现;zod 依赖在此包(dependencies,不是 peer) |
|
||||
| `class DomainImpl` | 写链 `chain: Promise<void>`(`enqueue<T>(job): Promise<T>` 私有方法,所有写走它);内存态 `Map<table, Map<key, value>>` + global;每写:链上 → 改内存 → unit 原语 await → `ctx.emit('domain/changed', …)`;dispose:`enqueue(noop)` 排空 → `unit.close()` |
|
||||
| `class KvTableImpl<K,V>` | 读同步走内存;`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<StorageBackend> }>`,两后端 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<string>`——`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<WorkspaceId, WorkspaceEntity>`;`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` 检查线)
|
||||
@@ -34,6 +34,8 @@ Packages live at `packages/<group>/<pkg>/`; 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 |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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/<anything>`). */
|
||||
export const API_PATH = '/api'
|
||||
@@ -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<void> {
|
||||
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<void>((resolve) => {
|
||||
const done = (): void => {
|
||||
res.off('drain', done)
|
||||
res.off('close', done)
|
||||
resolve()
|
||||
}
|
||||
res.once('drain', done)
|
||||
res.once('close', done)
|
||||
})
|
||||
}
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
@@ -1,4 +1,29 @@
|
||||
/** Host loader entry for the browser wire client exported from `./client`. */
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
// Activates the httpServer Context merge used below.
|
||||
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'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/**
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* @param ctx - Host plugin context.
|
||||
*/
|
||||
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')
|
||||
}
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -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<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -28,15 +28,20 @@
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-client-modules": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -64,20 +64,11 @@
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Entry, Loader } from '@cordisjs/plugin-loader'
|
||||
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
import type { PluginsEventFrame } from '../events.ts'
|
||||
import { EVENTS_ENDPOINT } from '../events.ts'
|
||||
|
||||
/**
|
||||
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
|
||||
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
|
||||
* wire boundary: frames arrive as JSON text and are validated at the parse
|
||||
* point, not shared as a same-process typed seam.
|
||||
*/
|
||||
export type PluginsEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
|
||||
export const EVENTS_ENDPOINT = '/plugins/events'
|
||||
export type { PluginsEventFrame } from '../events.ts'
|
||||
export { EVENTS_ENDPOINT } from '../events.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Wire protocol of the `/plugins/events` dev SSE channel — single source for
|
||||
* both halves of this package. Frames still cross a wire boundary: the
|
||||
* browser half validates them at its JSON parse point; sharing the type keeps
|
||||
* the two ends from drifting, not from parsing.
|
||||
*/
|
||||
|
||||
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
|
||||
export type PluginsEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
|
||||
export const EVENTS_ENDPOINT = '/plugins/events'
|
||||
@@ -1,9 +1,152 @@
|
||||
/**
|
||||
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
|
||||
* the host graph): the reload driver lives in its client half in full
|
||||
* (src/client/); the empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery).
|
||||
* HMR plugin, node half: the host end of the dev reload chain. Stat-polls
|
||||
* every graph row's client bundle (fs.watchFile — polling by design: network
|
||||
* mounts deliver no inotify events), reports content changes through
|
||||
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
|
||||
* broadcasting graph/rebuilt frames to the browser half (src/client/).
|
||||
* Dev-only row: prod compositions never mount this plugin.
|
||||
*/
|
||||
import type { Stats } from 'node:fs'
|
||||
import { unwatchFile, watchFile } from 'node:fs'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Empty type imports carry the clientModuleHost/httpServer Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-client-modules'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { PluginsEventFrame } from './events.ts'
|
||||
import { EVENTS_ENDPOINT } from './events.ts'
|
||||
|
||||
/** Host plugin body — no host-side behavior for the HMR plugin. */
|
||||
export function apply(): void {}
|
||||
export type { PluginsEventFrame } from './events.ts'
|
||||
export { EVENTS_ENDPOINT } from './events.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
|
||||
/** Required services: the web plugin table and the route registry. */
|
||||
export const inject = ['clientModuleHost', 'httpServer']
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
pollIntervalMs: z.number().step(1).min(1).default(500),
|
||||
})
|
||||
|
||||
/** Serialize one frame as an SSE data line. */
|
||||
function sseData(frame: PluginsEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
|
||||
* @param ctx - host plugin context carrying clientModuleHost and httpServer.
|
||||
* @param config - validated {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery's .default() guarantees the field is set after validation.
|
||||
const pollIntervalMs = config.pollIntervalMs as number
|
||||
|
||||
// --- bundle watch: one fs.watchFile stat poll per graph row -------------
|
||||
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
|
||||
|
||||
const watchRow = (id: string, path: string): void => {
|
||||
const listener = (curr: Stats, prev: Stats): void => {
|
||||
// fs.watchFile fires on any stat delta (atime included); only content
|
||||
// signals count. An all-zero curr means the file vanished mid-rebuild
|
||||
// — the completing write fires the next tick, so skipping is safe.
|
||||
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
|
||||
if (curr.mtimeMs === 0) return
|
||||
try {
|
||||
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
|
||||
// fires onRebuilt only on a real rev change). A torn read of a
|
||||
// half-written bundle self-heals on the next poll tick.
|
||||
ctx.clientModuleHost.rebuilt(id)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
|
||||
ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
watchFile(path, { interval: pollIntervalMs, persistent: false }, listener)
|
||||
watched.set(id, { path, listener })
|
||||
}
|
||||
|
||||
// Diff the watch set against the current graph: drop watches for removed
|
||||
// rows (or rows whose bundle path moved), add watches for new rows.
|
||||
const syncWatches = (): void => {
|
||||
const rows = new Map<string, string>()
|
||||
for (const row of ctx.clientModuleHost.graph().entries) {
|
||||
const path = ctx.clientModuleHost.clientPath(row.id)
|
||||
if (path !== undefined) rows.set(row.id, path)
|
||||
}
|
||||
for (const [id, watch] of watched) {
|
||||
if (rows.get(id) === watch.path) continue
|
||||
unwatchFile(watch.path, watch.listener)
|
||||
watched.delete(id)
|
||||
}
|
||||
for (const [id, path] of rows) {
|
||||
if (!watched.has(id)) watchRow(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
// Initial sync covers rows already in the graph; the subscription covers
|
||||
// rows arriving later (boot-window activations, including this plugin's
|
||||
// own row — no self-exemption, a modules/hmr rebuild rides the same chain).
|
||||
syncWatches()
|
||||
const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
|
||||
return () => {
|
||||
unsubscribe()
|
||||
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
|
||||
watched.clear()
|
||||
}
|
||||
}, 'client-hmr: bundle watches')
|
||||
|
||||
// --- /plugins/events SSE channel ----------------------------------------
|
||||
const connections = new Set<ServerResponse>()
|
||||
|
||||
const connect = (res: ServerResponse): void => {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
'connection': 'keep-alive',
|
||||
})
|
||||
// Comment line on open so clients/proxies see a live channel even when
|
||||
// no rebuild ever happens; EventSource frame parsing skips it naturally.
|
||||
res.write(': connected\n\n')
|
||||
res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() }))
|
||||
connections.add(res)
|
||||
res.on('close', () => { connections.delete(res) })
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const disposeRoute = ctx.httpServer.register({
|
||||
kind: 'exact',
|
||||
path: EVENTS_ENDPOINT,
|
||||
handler: (req, res) => {
|
||||
// Named routes match ahead of the carrier's method gate; keep the old
|
||||
// global 405 semantics for non-GET hits on this endpoint.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
connect(res)
|
||||
},
|
||||
})
|
||||
const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => {
|
||||
const line = sseData({ type: 'rebuilt', id, rev })
|
||||
for (const res of connections) res.write(line)
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
disposeRoute()
|
||||
for (const res of connections) res.destroy()
|
||||
connections.clear()
|
||||
}
|
||||
}, 'client-hmr: /plugins/events channel')
|
||||
}
|
||||
@@ -3,8 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-client-hmr/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
|
||||
@@ -14,14 +13,42 @@ export const name = 'client-hmr-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
|
||||
function statWatchers(): number {
|
||||
return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
|
||||
}
|
||||
|
||||
/**
|
||||
* No runtime invariant: a dev-only reload driver — it consumes the loader
|
||||
* entry tree and module cache but owns no events and no cross-plugin mutable
|
||||
* state; reload correctness (dispose → style removal → re-execute ordering)
|
||||
* is observable only through the assembled browser runtime, not a host-side
|
||||
* event relation.
|
||||
* Owned relation: every bundle stat watcher the node half starts must die
|
||||
* with its fiber — a surviving poller would keep re-hashing bundles for a
|
||||
* torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
|
||||
* count observed at fiber creation must be restored once disposal has drained
|
||||
* the fiber's effects (`internal/plugin` fires at dispose start; the microtask
|
||||
* hop lets the disposer queue its unload before `fiber.await()` joins it).
|
||||
* SSE-connection and listener teardown live inside the same ctx.effect
|
||||
* disposers, so the watcher count is the relation's observable proxy.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
const baselines = new WeakMap<Fiber, number>()
|
||||
// Async listener by design: emitPluginDisposed awaits-and-logs returned
|
||||
// promises, so a violation surfaces loudly instead of unhandled.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
ctx.on('internal/plugin', async (fiber) => {
|
||||
if (fiber.name !== 'client-hmr') return
|
||||
if (fiber.uid !== null) {
|
||||
baselines.set(fiber, statWatchers())
|
||||
return
|
||||
}
|
||||
const baseline = baselines.get(fiber)
|
||||
if (baseline === undefined) return
|
||||
await Promise.resolve()
|
||||
await fiber.await()
|
||||
const remaining = statWatchers()
|
||||
if (remaining > baseline) {
|
||||
fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
@@ -30,4 +57,3 @@ const install: InvariantInstaller = () => {}
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,14 +1,116 @@
|
||||
/**
|
||||
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
|
||||
* lives in the client half) whose only contract is mounting and disposing
|
||||
* cleanly in the host Loader.
|
||||
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
|
||||
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '@deepseek-ai/dsh-client-hmr'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
|
||||
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
|
||||
|
||||
const POLL_MS = 20
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
|
||||
/**
|
||||
* Controllable clientModuleHost fake over a mutable id → bundle-path table.
|
||||
* Structural (Pick+cast): the plugin only touches the read/notify surface;
|
||||
* the service class carries private scan state a literal need not reproduce.
|
||||
*/
|
||||
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
|
||||
function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
|
||||
const graphListeners = new Set<() => void>()
|
||||
const rebuiltCalls: string[] = []
|
||||
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
|
||||
rebuiltCalls,
|
||||
fireGraphChanged: () => { for (const l of graphListeners) l() },
|
||||
graph: (): WebBootGraph => ({
|
||||
rev: 'r',
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
||||
}),
|
||||
clientPath: id => rows.get(id),
|
||||
rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
|
||||
onRebuilt: () => () => {},
|
||||
onGraphChanged: (listener) => {
|
||||
graphListeners.add(listener)
|
||||
return () => { graphListeners.delete(listener) }
|
||||
},
|
||||
}
|
||||
return fake as FakeHost
|
||||
}
|
||||
|
||||
// Structural fake: the plugin only touches register(); the service class
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
function fakeHttpServer(routes: WebRoute[]): HttpServerService {
|
||||
const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
return fake as HttpServerService
|
||||
}
|
||||
|
||||
async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
|
||||
const ctx = new Context()
|
||||
ctx.provide('clientModuleHost', clientModuleHost)
|
||||
ctx.provide('httpServer', httpServer)
|
||||
const fiber = ctx.plugin(
|
||||
{ inject: [...inject], Config, apply },
|
||||
{ pollIntervalMs: POLL_MS },
|
||||
)
|
||||
await fiber.await()
|
||||
return fiber
|
||||
}
|
||||
|
||||
describe('hmr node half', () => {
|
||||
it('apply is a no-op host placeholder', () => {
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
|
||||
const bundle = join(dir, 'a.js')
|
||||
writeFileSync(bundle, 'v1')
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
|
||||
const routes: WebRoute[] = []
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
|
||||
|
||||
// Nudge mtime past stat granularity so the poller sees a content signal.
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(bundle, 'v2-longer')
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
// Watcher gone: further file changes report nothing.
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
writeFileSync(bundle, 'v3-even-longer')
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
|
||||
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('follows graph changes: rows added after activation get watched', async () => {
|
||||
const early = join(dir, 'early.js')
|
||||
const late = join(dir, 'late.js')
|
||||
writeFileSync(early, 'v1')
|
||||
const rows = new Map([['pkg-early', early]])
|
||||
const clientModuleHost = fakeClientModuleHost(rows)
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
|
||||
writeFileSync(late, 'v1')
|
||||
rows.set('pkg-late', late)
|
||||
clientModuleHost.fireGraphChanged()
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(late, 'v2-longer')
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
@@ -23,6 +23,12 @@
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-modules",
|
||||
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -11,6 +11,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
@@ -18,14 +22,26 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"platform": "web",
|
||||
"inject": [],
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Browser half (the standard `./client` export): the module-system class and
|
||||
* wire contract, plus the enrollment plugin face. The module system itself is
|
||||
* built by the shell kernel BEFORE cordis exists (the bootstrap exception,
|
||||
* design §4.7 — the mechanism that loads plugins cannot arrive through
|
||||
* itself); the plugin face only enrolls that pre-existing instance by
|
||||
* providing it as `ctx.modules`. The kernel statically registers this module,
|
||||
* so the graph row for this package never triggers a real fetch — arrival is
|
||||
* a no-op against the already-registered entry.
|
||||
* @module @deepseek-ai/dsh-client-modules/client
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { DshWindow } from './manifest.ts'
|
||||
|
||||
export { ClientModuleSystem } from './system.ts'
|
||||
export { parseBootManifest } from './manifest.ts'
|
||||
export type {
|
||||
BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
|
||||
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph,
|
||||
} from './manifest.ts'
|
||||
|
||||
/**
|
||||
* Enroll the kernel-built module system as `ctx.modules`.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const modules = (globalThis as DshWindow).__DSH_MODULES__
|
||||
// The kernel writes the slot right after constructing the instance, before
|
||||
// any cordis entry exists — a missing slot means the kernel sequencing broke.
|
||||
if (modules === undefined) {
|
||||
throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot')
|
||||
}
|
||||
ctx.reflect.provide('modules', modules)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Client module system: the browser peer of Node's internal ESM loader, built
|
||||
* as a lazy CJS table. The vendored cordis Loader consumes this object
|
||||
* through its `internal` seam (the only call site is `EntryTree.import` →
|
||||
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
|
||||
* waiting, update/refresh) entirely on the vendored side while this package
|
||||
* owns code arrival.
|
||||
*
|
||||
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
|
||||
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
|
||||
* side effect — including CSS injection — lives inside the factory closure
|
||||
* and runs at materialization, not at script execution. Materialization
|
||||
* (factory(require) → export surface) happens on first import/require and is
|
||||
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
|
||||
* another registered-but-unmaterialized module materializes it recursively,
|
||||
* so load order needs no external sequencing.
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → fetch + execute +
|
||||
* materialize; anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
* factories walks the same order minus the fetch branch: fetching is async,
|
||||
* so only already-executed bundles can be required — and cross-plugin value
|
||||
* imports are a build error anyway.
|
||||
*
|
||||
* This file is the browser-safe contract face (zero node imports): the
|
||||
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around
|
||||
* {@link ClientModuleSystem}. The package root is the host-side service that
|
||||
* composes the wire.
|
||||
*/
|
||||
|
||||
import type {} from 'cordis'
|
||||
import type { ClientModuleSystem } from './system.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */
|
||||
modules: ClientModuleLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One composed client entry pushed by the host (web2 §0 graph row). Wire
|
||||
* single source: the host node half (package root) produces this same shape.
|
||||
* `immediately` marks stage-one prefetch; `inject` is informational graph
|
||||
* metadata (the authoritative edges live in each package's dshClient
|
||||
* declaration and reach fibers through entry creation).
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
|
||||
url: string
|
||||
/** Bundle content hash (cache-busting consistency anchor). */
|
||||
rev: string
|
||||
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
|
||||
inject?: string[]
|
||||
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over the whole graph (content + bundle hashes). */
|
||||
rev: string
|
||||
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
|
||||
entries: WebBootEntry[]
|
||||
}
|
||||
|
||||
/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
|
||||
export interface BootModuleRow {
|
||||
/** Entry name == package name (module-table key). */
|
||||
id: string
|
||||
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
|
||||
url: string
|
||||
/** Bundle content hash. */
|
||||
rev: string
|
||||
}
|
||||
|
||||
/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
|
||||
export interface BootPluginRow {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Package-name dependency edges ([] when the wire omits them). */
|
||||
inject: string[]
|
||||
/** Stage-one prefetch tier (false when the wire omits it). */
|
||||
immediately: boolean
|
||||
}
|
||||
|
||||
/** The parsed boot manifest: one wire, two consumer views. */
|
||||
export interface BootManifest {
|
||||
/** Consistency anchor over the whole graph. */
|
||||
rev: string
|
||||
/** Rows as the module table consumes them. */
|
||||
modules: BootModuleRow[]
|
||||
/** Rows as entry composition consumes them. */
|
||||
plugins: BootPluginRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
|
||||
* a missing or malformed graph throws (the shell shows the loud failure —
|
||||
* a page without a valid manifest cannot boot anything).
|
||||
* @param wire - the raw `window.__DSH_BOOT__` value.
|
||||
* @returns the manifest with optional plugin-view fields normalized.
|
||||
*/
|
||||
export function parseBootManifest(wire: unknown): BootManifest {
|
||||
if (typeof wire !== 'object' || wire === null) {
|
||||
throw new Error('client-modules: window.__DSH_BOOT__ is missing or not an object')
|
||||
}
|
||||
const graph = wire as Record<string, unknown>
|
||||
if (typeof graph.rev !== 'string') {
|
||||
throw new Error('client-modules: boot manifest rev must be a string')
|
||||
}
|
||||
if (!Array.isArray(graph.entries)) {
|
||||
throw new Error('client-modules: boot manifest entries must be an array')
|
||||
}
|
||||
const modules: BootModuleRow[] = []
|
||||
const plugins: BootPluginRow[] = []
|
||||
for (const value of graph.entries as unknown[]) {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('client-modules: boot manifest entry is not an object')
|
||||
}
|
||||
const row = value as Record<string, unknown>
|
||||
const where = typeof row.id === 'string' ? `"${row.id}"` : JSON.stringify(row)
|
||||
if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
|
||||
throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
|
||||
}
|
||||
if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) {
|
||||
throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`)
|
||||
}
|
||||
if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
|
||||
throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
|
||||
}
|
||||
modules.push({ id: row.id, url: row.url, rev: row.rev })
|
||||
plugins.push({
|
||||
id: row.id,
|
||||
inject: row.inject === undefined ? [] : [...row.inject as string[]],
|
||||
immediately: row.immediately === true,
|
||||
})
|
||||
}
|
||||
return { rev: graph.rev, modules, plugins }
|
||||
}
|
||||
|
||||
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
|
||||
id: string
|
||||
/**
|
||||
* Closure factory holding the whole bundle body: receives the synchronous
|
||||
* require bound to the module table and returns the bundle's export
|
||||
* surface. Runs once, at materialization.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
|
||||
__DSH_BOOT__?: unknown
|
||||
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor (contract C6). */
|
||||
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
|
||||
/**
|
||||
* Kernel handoff slot: the shell kernel stores the instance here right
|
||||
* after construction (before cordis exists) so the `./client` wrapper
|
||||
* plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
|
||||
* time = kernel sequencing bug, thrown loud.
|
||||
*/
|
||||
__DSH_MODULES__?: ClientModuleSystem
|
||||
}
|
||||
|
||||
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
|
||||
export interface ClientModuleRecord {
|
||||
/** Module id (entry name / package name). */
|
||||
id: string
|
||||
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
|
||||
surface: unknown
|
||||
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
|
||||
edges: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal-seam subset the vendored Loader and the client HMR plugin
|
||||
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
|
||||
* as `ctx.modules` (contract C5).
|
||||
*/
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
version: 'client'
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
/**
|
||||
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
|
||||
* `specifier` through the branch order documented on the module, fetching
|
||||
* and executing a bundle when needed.
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - import attributes (unused; interface parity with Node's seam).
|
||||
* @returns the module's export surface.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Register a shell-own module (app-shell — code that ships inside the shell
|
||||
* bundle and never arrives as a plugin bundle).
|
||||
* @param id - entry name (shell-owned pseudo id).
|
||||
* @param module - the statically imported module namespace.
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* No-op for static-registered ids and ids whose factory is already
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
}
|
||||
|
||||
/** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
|
||||
export interface ClientModuleSystemOptions {
|
||||
/** Boot rows in the module-table view (from {@link parseBootManifest}). */
|
||||
modules: BootModuleRow[]
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
+17
-25
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
|
||||
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
|
||||
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* documented on the package module and the public interfaces in `./index.ts`;
|
||||
* this file owns the state tables and the fetch/execute/materialize machinery.
|
||||
* documented on the public interfaces in `./manifest.ts`; this file owns the
|
||||
* state tables and the fetch/execute/materialize machinery.
|
||||
*/
|
||||
import type {
|
||||
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
|
||||
ClientPluginHandoff, DshWindow, WebBootEntry,
|
||||
} from './index.ts'
|
||||
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
|
||||
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
|
||||
} from './manifest.ts'
|
||||
|
||||
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
|
||||
interface RegisteredFactory {
|
||||
@@ -35,13 +35,6 @@ const defaultExecuteBundle = (code: string, url: string): void => {
|
||||
el.remove()
|
||||
}
|
||||
|
||||
const urlOf = (row: WebBootEntry): string => {
|
||||
// url is conditional on the wire (shell-own pseudo rows omit it); those
|
||||
// ids resolve through the static registry and never reach a fetch.
|
||||
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
|
||||
return row.url
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
* subpath external bundles emit) and the bare graph id name the same
|
||||
@@ -70,10 +63,10 @@ const claimStyles = (id: string): string[] => {
|
||||
/**
|
||||
* The client module system: state tables plus the arrival/materialization
|
||||
* machinery implementing {@link ClientModuleLoader} (whose members carry the
|
||||
* seam contract docs). Construction indexes the boot graph and installs the
|
||||
* seam contract docs). Construction indexes the boot rows and installs the
|
||||
* `window.__ModuleLoader__` registration sink (contract C6) — once per page.
|
||||
*/
|
||||
export class ClientModuleLoaderImpl implements ClientModuleLoader {
|
||||
export class ClientModuleSystem implements ClientModuleLoader {
|
||||
readonly version = 'client'
|
||||
readonly loadCache = new Map<string, ClientModuleRecord>()
|
||||
|
||||
@@ -84,7 +77,7 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
|
||||
private readonly pendingArrival = new Map<string, Promise<void>>()
|
||||
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, WebBootEntry>()
|
||||
private readonly graphRows = new Map<string, BootModuleRow>()
|
||||
// Execution URL of the bundle currently being executed (bound into the
|
||||
// factory registration so diagnostics can name the source).
|
||||
private executingUrl = ''
|
||||
@@ -97,17 +90,17 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
|
||||
private readonly executeBundle: (code: string, url: string) => void
|
||||
|
||||
/**
|
||||
* Build the module system over the host graph.
|
||||
* @param options - entry graph, module-table staticModules, fetch/execute seams.
|
||||
* Build the module system over the parsed boot rows.
|
||||
* @param options - module rows, module-table staticModules, fetch/execute seams.
|
||||
*/
|
||||
constructor(options: ClientModuleLoaderOptions) {
|
||||
constructor(options: ClientModuleSystemOptions) {
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
|
||||
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
|
||||
|
||||
for (const entry of options.graph.entries) {
|
||||
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
|
||||
this.graphRows.set(entry.id, entry)
|
||||
for (const row of options.modules) {
|
||||
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
|
||||
this.graphRows.set(row.id, row)
|
||||
}
|
||||
|
||||
const win = globalThis as DshWindow
|
||||
@@ -129,13 +122,12 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
|
||||
}
|
||||
|
||||
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
private arrive(row: WebBootEntry): Promise<void> {
|
||||
const { id } = row
|
||||
private arrive(row: BootModuleRow): Promise<void> {
|
||||
const { id, url } = row
|
||||
const pending = this.pendingArrival.get(id)
|
||||
if (pending !== undefined) return pending
|
||||
if (this.factories.has(id)) return Promise.resolve()
|
||||
const task = (async (): Promise<void> => {
|
||||
const url = urlOf(row)
|
||||
const code = await this.fetchBundle(url)
|
||||
this.executingUrl = url
|
||||
this.executingId = id
|
||||
@@ -1,175 +1,393 @@
|
||||
/**
|
||||
* Client module system: the browser peer of Node's internal ESM loader, built
|
||||
* as a lazy CJS table. The vendored cordis Loader consumes this object
|
||||
* through its `internal` seam (the only call site is `EntryTree.import` →
|
||||
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
|
||||
* waiting, update/refresh) entirely on the vendored side while this package
|
||||
* owns code arrival.
|
||||
* Node half of the client module system (dshClient dual-face package): scans
|
||||
* the host Loader's entries for `dshClient` packages, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
|
||||
* index render to inject the boot manifest, and provides the
|
||||
* `clientModuleHost` service (the HMR node half's registration/notification
|
||||
* face).
|
||||
*
|
||||
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
|
||||
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
|
||||
* side effect — including CSS injection — lives inside the factory closure
|
||||
* and runs at materialization, not at script execution. Materialization
|
||||
* (factory(require) → export surface) happens on first import/require and is
|
||||
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
|
||||
* another registered-but-unmaterialized module materializes it recursively,
|
||||
* so load order needs no external sequencing.
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → fetch + execute +
|
||||
* materialize; anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
* factories walks the same order minus the fetch branch: fetching is async,
|
||||
* so only already-executed bundles can be required — and cross-plugin value
|
||||
* imports are a build error anyway.
|
||||
* Scanning is incremental per package — there is no full-rescan code path.
|
||||
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
|
||||
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
|
||||
* against the live loader entries. The activation pass seeds the same dirty
|
||||
* set with all current entries and flushes synchronously, so first scan and
|
||||
* steady state share one implementation. Package metadata (including the
|
||||
* negative "not a client package" verdict) is cached per name and never
|
||||
* expires — plugin-set changes take effect on restart per the config-source
|
||||
* ruling; bundle content changes reach the graph only through
|
||||
* {@link ClientModuleHostService.rebuilt}.
|
||||
* @module @deepseek-ai/dsh-client-modules
|
||||
*/
|
||||
|
||||
import { ClientModuleLoaderImpl } from './loader.ts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
|
||||
|
||||
export { ClientModuleLoaderImpl }
|
||||
export type {
|
||||
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
|
||||
} from './client/manifest.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The client module system the web shell provides at boot (contract C5). */
|
||||
modules: ClientModuleLoader
|
||||
/** The web plugin table (provided by the client-modules node half). */
|
||||
clientModuleHost: ClientModuleHostService
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
|
||||
interface DshClientDeclaration {
|
||||
inject?: string[]
|
||||
platform: string
|
||||
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
|
||||
interface PkgMeta {
|
||||
clientPath: string
|
||||
inject?: string[]
|
||||
immediately: boolean
|
||||
}
|
||||
|
||||
/** One composed table row: the wire entry plus its bundle path. */
|
||||
interface WebPluginRecord {
|
||||
entry: WebBootEntry
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
|
||||
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
|
||||
}
|
||||
const decl = value as Record<string, unknown>
|
||||
if (typeof decl.platform !== 'string') {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`)
|
||||
}
|
||||
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`)
|
||||
}
|
||||
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
|
||||
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
|
||||
function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
|
||||
if (typeof exportsField !== 'object' || exportsField === null) return undefined
|
||||
const client = (exportsField as Record<string, unknown>)['./client']
|
||||
if (client === undefined) return undefined
|
||||
if (typeof client === 'string') return client
|
||||
if (typeof client === 'object' && client !== null) {
|
||||
const fallback = (client as Record<string, unknown>).default
|
||||
if (typeof fallback === 'string') return fallback
|
||||
}
|
||||
throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`)
|
||||
}
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
|
||||
function graphRow(id: string, rev: string, injectEdges: string[] | undefined, immediately: boolean): WebBootEntry {
|
||||
return {
|
||||
id,
|
||||
url: `/plugins/${id}/client.js?rev=${rev}`,
|
||||
rev,
|
||||
...(injectEdges !== undefined ? { inject: injectEdges } : {}),
|
||||
...(immediately ? { immediately: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One composed client entry pushed by the host (web2 §0 graph row).
|
||||
* `immediately` marks stage-one prefetch; `inject` is informational graph
|
||||
* metadata (the authoritative edges live in each package's dshClient
|
||||
* declaration and reach fibers through entry creation).
|
||||
*
|
||||
* Wire contract, held on both sides: the producing peer lives in
|
||||
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
|
||||
* dependencies, so neither side imports the other's shape — drift between
|
||||
* the two declarations is a bug against the web2 contract).
|
||||
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
|
||||
* first script in <head> (before the shell bundle reads it). `<` is escaped in
|
||||
* the JSON so plugin-controlled strings cannot break out of the script element.
|
||||
* @param html - the index.html source.
|
||||
* @param graph - the composed entry graph.
|
||||
* @returns the html with the graph script injected.
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
|
||||
id: string
|
||||
/**
|
||||
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
|
||||
* shell-owned pseudo rows (app-shell) whose module is statically registered
|
||||
* — a row that is neither fetchable nor static-registered fails loud.
|
||||
*/
|
||||
url?: string
|
||||
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
|
||||
rev?: string
|
||||
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
|
||||
inject?: string[]
|
||||
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over the whole graph (content + bundle hashes). */
|
||||
rev: string
|
||||
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
|
||||
entries: WebBootEntry[]
|
||||
}
|
||||
|
||||
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
|
||||
id: string
|
||||
/**
|
||||
* Closure factory holding the whole bundle body: receives the synchronous
|
||||
* require bound to the module table and returns the bundle's export
|
||||
* surface. Runs once, at materialization.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs. */
|
||||
__DSH_BOOT__?: WebBootGraph
|
||||
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
|
||||
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
|
||||
}
|
||||
|
||||
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
|
||||
export interface ClientModuleRecord {
|
||||
/** Module id (entry name / package name). */
|
||||
id: string
|
||||
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
|
||||
surface: unknown
|
||||
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
|
||||
edges: Set<string>
|
||||
export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
|
||||
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
|
||||
const head = html.indexOf('<head>')
|
||||
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
|
||||
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
|
||||
return `${script}${html}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal-seam subset the vendored Loader and the client HMR plugin
|
||||
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
|
||||
* as `ctx.modules` (contract C5).
|
||||
* 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).
|
||||
*/
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
version: 'client'
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
export class ClientModuleHostService extends Service {
|
||||
static inject = ['httpServer', 'loader']
|
||||
|
||||
private readonly table = new Map<string, WebPluginRecord>()
|
||||
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
|
||||
// subpath rows — or a package without a web dshClient declaration) are
|
||||
// cached as null and never expire: plugin-set changes take effect on restart.
|
||||
private readonly pkgMeta = new Map<string, PkgMeta | null>()
|
||||
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
private readonly graphListeners = new Set<() => void>()
|
||||
private readonly dirty = new Set<string>()
|
||||
private readonly resolvePkgJson: (spec: string) => string
|
||||
private flushQueued = false
|
||||
private composed: WebBootGraph
|
||||
|
||||
/**
|
||||
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
|
||||
* `specifier` through the branch order documented on the module, fetching
|
||||
* and executing a bundle when needed.
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - import attributes (unused; interface parity with Node's seam).
|
||||
* @returns the module's export surface.
|
||||
* Build the service: subscribe, seed, and run the activation flush.
|
||||
* @param ctx - plugin context carrying httpServer and loader.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'clientModuleHost')
|
||||
// Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
|
||||
// whose package declares every composed plugin as a dependency). The
|
||||
// modules package's own URL would miss sibling packages under pnpm's
|
||||
// isolated node_modules.
|
||||
if (ctx.baseUrl === undefined) {
|
||||
throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
|
||||
}
|
||||
const require = createRequire(ctx.baseUrl)
|
||||
this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
|
||||
|
||||
// Subscribe before seeding so a fiber arriving mid-activation lands in the
|
||||
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
|
||||
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
const entryName = fiber.entry?.options.name
|
||||
if (entryName === undefined) return
|
||||
this.dirty.add(entryName)
|
||||
if (this.flushQueued) return
|
||||
this.flushQueued = true
|
||||
queueMicrotask(() => {
|
||||
this.flushQueued = false
|
||||
this.flush((err) => { ctx.logger.warn(err) })
|
||||
})
|
||||
})
|
||||
|
||||
// Activation pass: the initial scan IS the incremental path over the
|
||||
// current entries, flushed synchronously (nothing async between subscribe,
|
||||
// seed, and flush).
|
||||
for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
|
||||
this.composed = this.compose()
|
||||
const failures: Error[] = []
|
||||
this.flush(err => failures.push(err))
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
`client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
|
||||
)
|
||||
}
|
||||
|
||||
ctx.effect(
|
||||
() => ctx.httpServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
|
||||
'client-modules: bundle route',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.httpServer.tapIndex(html => injectBootManifest(html, this.composed)),
|
||||
'client-modules: boot manifest injection',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a shell-own module (app-shell — code that ships inside the shell
|
||||
* bundle and never arrives as a plugin bundle).
|
||||
* @param id - entry name (shell-owned pseudo id).
|
||||
* @param module - the statically imported module namespace.
|
||||
* Current composed entry graph (stable object between changes).
|
||||
* @returns the graph served as `window.__DSH_BOOT__`.
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
graph(): WebBootGraph {
|
||||
return this.composed
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* No-op for static-registered ids and ids whose factory is already
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
* Absolute path of an entry's client bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the path, or undefined for an unknown id.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
clientPath(id: string): string | undefined {
|
||||
return this.table.get(id)?.clientPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
* 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.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
rebuilt(id: string): string | undefined {
|
||||
const record = this.table.get(id)
|
||||
if (record === undefined) return undefined
|
||||
const rev = shortHash(readFileSync(record.clientPath))
|
||||
if (rev === record.entry.rev) return rev
|
||||
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
|
||||
this.composed = this.compose()
|
||||
for (const notify of this.rebuildListeners) {
|
||||
// Containment: rebuilt() runs inside the HMR watch callback — a
|
||||
// throwing subscriber must not kill the poll or skip later subscribers.
|
||||
try {
|
||||
notify(id, rev)
|
||||
} catch (error) {
|
||||
this.ctx.logger.error(error)
|
||||
}
|
||||
}
|
||||
this.notifyGraphChanged()
|
||||
return rev
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.rebuildListeners.add(listener)
|
||||
return () => { this.rebuildListeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.graphListeners.add(listener)
|
||||
return () => { this.graphListeners.delete(listener) }
|
||||
}
|
||||
|
||||
private compose(): WebBootGraph {
|
||||
const entries = [...this.table.values()].map(record => record.entry)
|
||||
return { rev: shortHash(JSON.stringify(entries)), entries }
|
||||
}
|
||||
|
||||
private notifyGraphChanged(): void {
|
||||
for (const listener of this.graphListeners) {
|
||||
// A throwing subscriber must not skip later subscribers (or escape into
|
||||
// whatever triggered the flush — possibly an fs.watchFile callback).
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
this.ctx.logger.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveMeta(pkgName: string): PkgMeta | null {
|
||||
const cached = this.pkgMeta.get(pkgName)
|
||||
if (cached !== undefined) return cached
|
||||
let pkgPath: string
|
||||
try {
|
||||
pkgPath = this.resolvePkgJson(pkgName)
|
||||
} catch {
|
||||
// Not a resolvable package root: loader builtins (cordis:include) and
|
||||
// subpath entries (…/gateway) land here — permanently not a client row.
|
||||
this.pkgMeta.set(pkgName, null)
|
||||
return null
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
|
||||
const decl = parseDshClient(pkgName, pkg.dshClient)
|
||||
if (decl === undefined || decl.platform !== 'web') {
|
||||
this.pkgMeta.set(pkgName, null)
|
||||
return null
|
||||
}
|
||||
const clientRel = clientExportOf(pkgName, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`)
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
|
||||
immediately: decl.immediately === true,
|
||||
}
|
||||
this.pkgMeta.set(pkgName, meta)
|
||||
return meta
|
||||
}
|
||||
|
||||
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
|
||||
private processOne(entryName: string): boolean {
|
||||
let qualifies = false
|
||||
for (const entry of this.ctx.loader.entries()) {
|
||||
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
|
||||
qualifies = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!qualifies) return this.table.delete(entryName)
|
||||
if (this.table.has(entryName)) return false
|
||||
const meta = this.resolveMeta(entryName)
|
||||
if (meta === null) return false
|
||||
// The rev rides the row from here on: a fiber restart reuses the row (and
|
||||
// its rev) untouched; only rebuilt() re-reads the bundle.
|
||||
const rev = shortHash(readFileSync(meta.clientPath))
|
||||
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
|
||||
return true
|
||||
}
|
||||
|
||||
private flush(onError: (err: Error) => void): void {
|
||||
let changed = false
|
||||
for (const entryName of [...this.dirty]) {
|
||||
this.dirty.delete(entryName)
|
||||
try {
|
||||
if (this.processOne(entryName)) changed = true
|
||||
} catch (error) {
|
||||
// Steady state: one broken package must not poison the others; the
|
||||
// activation pass aggregates these into a loud throw instead.
|
||||
onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.composed = this.compose()
|
||||
this.notifyGraphChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
||||
// The id may contain a scope slash. Anything else under /plugins (including
|
||||
// /plugins/events when the HMR row is absent) is an unknown resource.
|
||||
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
|
||||
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
|
||||
: undefined
|
||||
if (path === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path)
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
|
||||
export interface ClientModuleLoaderOptions {
|
||||
/** Host-composed entry graph. */
|
||||
graph: WebBootGraph
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the client module system.
|
||||
* @param options - entry graph, module-table staticModules, fetch/execute seams.
|
||||
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
|
||||
*/
|
||||
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
|
||||
return new ClientModuleLoaderImpl(options)
|
||||
}
|
||||
export default ClientModuleHostService
|
||||
@@ -15,14 +15,25 @@ export const name = 'client-modules-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the module loader is pre-plugin kernel machinery —
|
||||
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
|
||||
* and its mutable state (loadCache, handoff slot) lives below the plugin
|
||||
* layer where invariant observers cannot mount before it runs; resolve branch
|
||||
* order and handoff discipline are asserted by the web boot specs against the
|
||||
* real execution path.
|
||||
* Owned relation: the node half's boot entry graph must stay self-consistent
|
||||
* — every row must resolve a clientPath under the same id (the
|
||||
* /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
|
||||
* that just received the graph). Checked on every scan trigger (cordis
|
||||
* 'internal/plugin'): graph() and clientPath() read the same table object,
|
||||
* so the relation holds at any instant — no need to wait out the node half's
|
||||
* own microtask-debounced flush.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', () => {
|
||||
const host = ctx.get('clientModuleHost')
|
||||
if (host === undefined) return // browser side / host without the node half: nothing to audit
|
||||
for (const row of host.graph().entries) {
|
||||
if (host.clientPath(row.id) === undefined) {
|
||||
fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
|
||||
}
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
|
||||
* ClientModuleSystem behavior: lazy CJS arrival (bundle execution only
|
||||
* registers the factory), materialization on first import/require with
|
||||
* memoization and recursive self-sequencing, the resolution branch order,
|
||||
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ClientModuleLoaderImpl, createClientModuleLoader,
|
||||
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
|
||||
} from '../src/index.ts'
|
||||
ClientModuleSystem,
|
||||
type BootModuleRow, type ClientModuleLoader, type ClientPluginHandoff, type DshWindow,
|
||||
} from '../src/client/index.ts'
|
||||
|
||||
const win = globalThis as DshWindow
|
||||
|
||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
for (const el of document.querySelectorAll('style, script')) el.remove()
|
||||
})
|
||||
|
||||
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
|
||||
const row = (id: string): BootModuleRow => ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0' })
|
||||
|
||||
interface Bench {
|
||||
loader: ClientModuleLoader
|
||||
@@ -38,14 +38,14 @@ interface Bench {
|
||||
* through the window sink (`null` scripts a bundle that never calls load).
|
||||
*/
|
||||
function bench(
|
||||
entries: WebBootEntry[],
|
||||
entries: BootModuleRow[],
|
||||
bundles: Record<string, Factory | null> = {},
|
||||
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
|
||||
): Bench {
|
||||
const fetched: string[] = []
|
||||
const gates = new Map<string, () => void>()
|
||||
const loader = createClientModuleLoader({
|
||||
graph: { rev: 'test', entries },
|
||||
const loader = new ClientModuleSystem({
|
||||
modules: entries,
|
||||
staticModules: opts.seed ?? {},
|
||||
fetchBundle: (url) => {
|
||||
fetched.push(url)
|
||||
@@ -175,7 +175,7 @@ describe('require resolution', () => {
|
||||
describe('static registry', () => {
|
||||
it('serves shell-own modules to import and require without any fetch', async () => {
|
||||
const shell = { marker: 'app-shell' }
|
||||
const b = bench([row('a'), { id: 'app-shell' }], {
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ dep: req('app-shell') }),
|
||||
})
|
||||
b.loader.registerStatic('app-shell', shell)
|
||||
@@ -216,18 +216,13 @@ describe('failure modes', () => {
|
||||
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
|
||||
})
|
||||
|
||||
it('a graph row with no url and no static registration is loud', async () => {
|
||||
const b = bench([{ id: 'ghost' }])
|
||||
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
|
||||
})
|
||||
|
||||
it('a duplicate graph entry is loud at construction', () => {
|
||||
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
|
||||
})
|
||||
|
||||
it('double boot is loud', () => {
|
||||
bench([])
|
||||
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
|
||||
expect(() => new ClientModuleSystem({ modules: [], staticModules: {} }))
|
||||
.toThrow('already installed (double boot?)')
|
||||
})
|
||||
})
|
||||
@@ -289,7 +284,7 @@ describe('default transport seams', () => {
|
||||
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
|
||||
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
|
||||
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
|
||||
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
@@ -300,7 +295,7 @@ describe('default transport seams', () => {
|
||||
|
||||
it('a non-ok bundle response is loud with the status', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
|
||||
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
|
||||
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
|
||||
})
|
||||
})
|
||||
@@ -3,22 +3,14 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/loader" },
|
||||
{ "path": "../../host/webserver" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-modules', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-web
|
||||
|
||||
Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
|
||||
Web shell kernel: `new AppWebEntry(el, seams?).run()` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
|
||||
|
||||
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
|
||||
|
||||
|
||||
+208
-142
@@ -1,23 +1,32 @@
|
||||
/**
|
||||
* Web shell boot — the kernel face consumed by the apps/web entry. Everything
|
||||
* here is machinery that cannot itself be an entry, and none of it
|
||||
* Web shell boot kernel — the face consumed by the apps/web entry. Everything
|
||||
* here is machinery that cannot itself be a loader entry, and none of it
|
||||
* value-imports a plugin package (web2 shell self-sufficiency rule: the
|
||||
* loading page must work while — especially when — plugins fail).
|
||||
* loading page must work while — especially when — plugins fail). The one
|
||||
* sanctioned exception is the modules package (design §4.7 bootstrap
|
||||
* identity): the module system cannot arrive through itself, so its class
|
||||
* and its client-half wrapper are shell-bundled and the kernel adopts its
|
||||
* plugin entry once cordis is up.
|
||||
*
|
||||
* Two-stage boot (web2 §0):
|
||||
* Stage one (module face): build the module system over the host graph
|
||||
* (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
|
||||
* — fetch + execute registers factories only; module side effects wait for
|
||||
* materialization. Prefetch failures are non-fatal here: stage two's
|
||||
* import path retries the fetch and owns the loud failure.
|
||||
* Stage two (plugin face): mount the vendored cordis Loader, inject the
|
||||
* module system as its internal seam (BEFORE any entry exists — the
|
||||
* bare-import fallback in tree.import must never run in a browser), create
|
||||
* one loader entry per graph row (tree.import materializes each module),
|
||||
* let fibers activate on service availability, then loader.await() + a
|
||||
* full fiber sweep (all ACTIVE, else reject listing who/what/which
|
||||
* service) → flip the settled signal so AppRoot switches to the real UI in
|
||||
* one pass.
|
||||
* AppWebEntry.run(), module face first, then plugin face: parse
|
||||
* `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary, D16)
|
||||
* → build the module system over the module-view rows → render the loading
|
||||
* page → prefetch every `immediately` row in parallel with mounting the
|
||||
* vendored cordis Loader (internal-seam injection BEFORE any entry exists —
|
||||
* the bare-import fallback in tree.import must never run in a browser) →
|
||||
* await the prefetch tier, THEN adopt the modules entry and create one
|
||||
* loader entry per plugin-view row plus the shell-own app-shell assembly
|
||||
* entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
|
||||
* listing who/what/which service) → flip the settled signal so AppRoot
|
||||
* switches to the real UI in one pass.
|
||||
*
|
||||
* Entry creation waits for the whole immediately tier: materialization runs
|
||||
* synchronous cross-package require edges (e.g. i18n → runtime/client) that
|
||||
* fiber inject waiting cannot protect — a bundle's factory must be
|
||||
* registered before any dependent entry materializes. Per-row prefetch
|
||||
* failures still resolve silently (the create-side import refetches and
|
||||
* owns the loud failure), so the barrier never turns one bad bundle into a
|
||||
* boot-wide fail-fast.
|
||||
*
|
||||
* Composition lives in the host graph; the shell makes zero composition
|
||||
* decisions (the app-shell assembly is itself a graph entry, the only
|
||||
@@ -25,148 +34,205 @@
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
|
||||
import {
|
||||
createClientModuleLoader,
|
||||
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
|
||||
} from '@deepseek-ai/dsh-client-modules'
|
||||
ClientModuleSystem, parseBootManifest,
|
||||
type BootManifest, type ClientModuleSystemOptions, type DshWindow,
|
||||
} from '@deepseek-ai/dsh-client-modules/client'
|
||||
import * as AppShell from './app-shell.ts'
|
||||
import { APP_SHELL_ID } from './app-shell.ts'
|
||||
import { AppRoot } from './AppRoot.tsx'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import {
|
||||
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
|
||||
export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
|
||||
|
||||
/**
|
||||
* Sweep every loader entry after the tree quiesced: an entry without a fiber
|
||||
* failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
|
||||
* (a required service never arrived — cordis inject waiting has no timeout,
|
||||
* so this sweep is the fail-loud compensation).
|
||||
* The modules package's own graph row id. The kernel adopts that entry
|
||||
* itself (its wrapper is statically registered — shell-bundled code, never
|
||||
* fetched), so the plugin-row loop must skip it: the vendored Group.create
|
||||
* does not deduplicate by name, and a second fiber would provide 'modules'
|
||||
* twice.
|
||||
*/
|
||||
function assertEntriesActive(ctx: Context): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
|
||||
async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
|
||||
await Promise.all(graph.entries
|
||||
.filter((row) => row.immediately === true)
|
||||
.map((row) => modules.prefetch(row.id).catch(() => {
|
||||
// Import (stage two) refetches and reports this loudly per entry;
|
||||
// swallowing here keeps one failing prefetch from masking the others.
|
||||
})))
|
||||
}
|
||||
|
||||
/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
|
||||
async function runPluginBoot(
|
||||
ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
|
||||
): Promise<void> {
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
// Inject the module system BEFORE any entry exists: tree.import falls back
|
||||
// to a bare dynamic import when internal is undefined, which in a browser
|
||||
// is a guaranteed loud failure — correct as a tripwire, never as a path.
|
||||
loader.internal = modules as never
|
||||
|
||||
// Status projection: AppRoot displays fiber truth. Every internal/status
|
||||
// transition under an entry re-projects that entry's row from its ROOT
|
||||
// fiber (child plugin fibers share the same entry).
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// fetches parallelize. The app-shell assembly entry is appended by the
|
||||
// kernel: it is shell-own code (host graph rows are all plugin bundles),
|
||||
// and mounting the assembly is not a composition decision — it rides the
|
||||
// same entry lifecycle so the sweep and status cover it uniformly.
|
||||
const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
status.set(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
// A failed import leaves the entry fiberless (Entry._init logs and
|
||||
// returns); project it as failed — no fiber means no status event.
|
||||
if (loader.resolve(id).fiber === undefined) {
|
||||
status.set(name, 'failed')
|
||||
}
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
assertEntriesActive(ctx)
|
||||
}
|
||||
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/**
|
||||
* Mount the web shell into a DOM element and start the two-stage boot chain.
|
||||
* @param el - mount point (the app's #root).
|
||||
* @param seams - optional module transport overrides (test environments).
|
||||
* @returns unmount disposer.
|
||||
* The web shell kernel: mounts the loading page into a DOM element and runs
|
||||
* the two-stage boot over the host graph. Fields hold only what must exist
|
||||
* before cordis does — the parsed manifest, the module system, and the
|
||||
* loading-page UI handles; everything else lives in plugins.
|
||||
*/
|
||||
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
|
||||
const graph = (globalThis as DshWindow).__DSH_BOOT__
|
||||
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
|
||||
export class AppWebEntry {
|
||||
private readonly el: HTMLElement
|
||||
private readonly seams: BootSeams | undefined
|
||||
private readonly status = createLoaderStatusStore()
|
||||
private readonly settled = createSignal(false)
|
||||
private readonly error = createSignal<string | undefined>(undefined)
|
||||
// Assigned by run() before any private method or settled-gated closure reads them.
|
||||
private ctx!: Context
|
||||
private modules!: ClientModuleSystem
|
||||
private manifest!: BootManifest
|
||||
private root: Root | undefined
|
||||
|
||||
const ctx = new Context()
|
||||
const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
|
||||
// The app-shell assembly is the only shell-own module: every other graph
|
||||
// row is a plugin bundle arriving through fetch (web2 single package form).
|
||||
modules.registerStatic(APP_SHELL_ID, AppShell)
|
||||
// Contract C5: the module system is a boot-owned kernel service (ctx.modules).
|
||||
ctx.reflect.provide('modules', modules)
|
||||
/**
|
||||
* Hold the mount point; all work happens in {@link run}.
|
||||
* @param el - mount point (the app's #root).
|
||||
* @param seams - optional module transport overrides (test environments).
|
||||
*/
|
||||
constructor(el: HTMLElement, seams?: BootSeams) {
|
||||
this.el = el
|
||||
this.seams = seams
|
||||
}
|
||||
|
||||
const status = createLoaderStatusStore()
|
||||
const settled = createSignal(false)
|
||||
const error = createSignal<string | undefined>(undefined)
|
||||
/**
|
||||
* Run the boot chain to settlement. Boot-chain failures resolve (not
|
||||
* reject): the loading page stays up and renders the failure report (the
|
||||
* fail-loud surface the kernel owns). Rejects only when the boot manifest
|
||||
* is missing or malformed — there is nothing to boot against.
|
||||
* @returns resolves once the UI settled or the failure report rendered.
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
|
||||
|
||||
const root = createRoot(el)
|
||||
root.render(
|
||||
<AppRoot
|
||||
settled={settled}
|
||||
status={status}
|
||||
error={error}
|
||||
renderApp={() => {
|
||||
const shell = ctx.get('appShell')
|
||||
// Unreachable after a clean settle (the app-shell entry is in every graph).
|
||||
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
||||
return shell.renderApp()
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
this.modules = new ClientModuleSystem({
|
||||
modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
|
||||
})
|
||||
// The app-shell assembly is the only shell-own module: every other graph
|
||||
// row is a plugin bundle arriving through fetch (web2 single package form).
|
||||
this.modules.registerStatic(APP_SHELL_ID, AppShell)
|
||||
// Adoption handoff, supply side (design §4.7): register the modules
|
||||
// package's own client half under its bare package name (= graph row id
|
||||
// = entry name — a suffixed key would miss the statics branch and
|
||||
// trigger a real fetch), and put the instance on the kernel slot the
|
||||
// wrapper's apply reads to provide ctx.modules.
|
||||
this.modules.registerStatic(MODULES_ID, ModulesClient)
|
||||
;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
|
||||
|
||||
prefetchImmediateTier(modules, graph)
|
||||
.then(() => runPluginBoot(ctx, modules, graph, status))
|
||||
.then(
|
||||
() => { settled.set(true) },
|
||||
(reason: unknown) => {
|
||||
// Stay on the loading page; surface the sweep report (fail loud).
|
||||
console.error(reason)
|
||||
error.set(reason instanceof Error ? reason.message : String(reason))
|
||||
},
|
||||
this.root = createRoot(this.el)
|
||||
this.root.render(
|
||||
<AppRoot
|
||||
settled={this.settled}
|
||||
status={this.status}
|
||||
error={this.error}
|
||||
renderApp={() => {
|
||||
const shell = this.ctx.get('appShell')
|
||||
// Unreachable after a clean settle (the app-shell entry is in every graph).
|
||||
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
||||
return shell.renderApp()
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
return () => { root.unmount() }
|
||||
|
||||
// The immediately tier prefetches in parallel with Loader mounting;
|
||||
// runPluginBoot awaits it before creating entries (see module comment:
|
||||
// cross-package synchronous require edges need every immediately-tier
|
||||
// factory registered before any materialization).
|
||||
const prefetching = this.prefetchImmediateTier()
|
||||
this.ctx = new Context()
|
||||
try {
|
||||
await this.runPluginBoot(prefetching)
|
||||
this.settled.set(true)
|
||||
} catch (reason) {
|
||||
// Stay on the loading page; surface the sweep report (fail loud).
|
||||
console.error(reason)
|
||||
this.error.set(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
/** Unmount the shell (loading page or settled UI). */
|
||||
dispose(): void {
|
||||
this.root?.unmount()
|
||||
}
|
||||
|
||||
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
|
||||
private async prefetchImmediateTier(): Promise<void> {
|
||||
await Promise.all(this.manifest.plugins
|
||||
.filter((row) => row.immediately)
|
||||
.map((row) => this.modules.prefetch(row.id).catch(() => {
|
||||
// Import refetches and reports this loudly per entry; swallowing
|
||||
// here keeps one failing prefetch from masking the others.
|
||||
})))
|
||||
}
|
||||
|
||||
/** Plugin face: mount the Loader, inject the internal seam, adopt modules, create the graph entries, settle, sweep. */
|
||||
private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
|
||||
const ctx = this.ctx
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
// Inject the module system BEFORE any entry exists: tree.import falls back
|
||||
// to a bare dynamic import when internal is undefined, which in a browser
|
||||
// is a guaranteed loud failure — correct as a tripwire, never as a path.
|
||||
loader.internal = this.modules as never
|
||||
|
||||
// Status projection: AppRoot displays fiber truth. Every internal/status
|
||||
// transition under an entry re-projects that entry's row from its ROOT
|
||||
// fiber (child plugin fibers share the same entry).
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
// Barrier before any entry exists: entry creation materializes bundles,
|
||||
// and materialization runs synchronous cross-package require edges that
|
||||
// need every immediately-tier factory already registered (module
|
||||
// comment). Resolves even when individual prefetches failed.
|
||||
await prefetching
|
||||
|
||||
// Adoption handoff, plugin side: the modules entry is created first —
|
||||
// its wrapper apply reads the kernel slot and provides ctx.modules (the
|
||||
// provide lives on the plugin face; see MODULES_ID for why the row loop
|
||||
// must then skip it).
|
||||
const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
|
||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// fetches parallelize. The app-shell assembly entry is appended by the
|
||||
// kernel: it is shell-own code (host graph rows are all plugin bundles),
|
||||
// and mounting the assembly is not a composition decision — it rides the
|
||||
// same entry lifecycle so the sweep and status cover it uniformly.
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
this.status.set(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
// A failed import leaves the entry fiberless (Entry._init logs and
|
||||
// returns); project it as failed — no fiber means no status event.
|
||||
if (loader.resolve(id).fiber === undefined) {
|
||||
this.status.set(name, 'failed')
|
||||
}
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
this.assertEntriesActive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep every loader entry after the tree quiesced: an entry without a
|
||||
* fiber failed its import; a fiber not ACTIVE is FAILED (apply threw) or
|
||||
* PENDING (a required service never arrived — cordis inject waiting has no
|
||||
* timeout, so this sweep is the fail-loud compensation).
|
||||
*/
|
||||
private assertEntriesActive(): void {
|
||||
const ctx = this.ctx
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Web shell library entry. The shell's product is {@link bootWebShell} —
|
||||
* apps/web's vite entry calls it against #root; everything else (AppRoot
|
||||
* Web shell library entry. The shell's product is {@link AppWebEntry} —
|
||||
* apps/web's vite entry runs it against #root; everything else (AppRoot
|
||||
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
|
||||
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
|
||||
* single source of truth for the tsdown client externals projection.
|
||||
* @module @deepseek-ai/dsh-client-web
|
||||
*/
|
||||
|
||||
export { bootWebShell, type BootSeams } from './boot.tsx'
|
||||
export { AppWebEntry, type BootSeams } from './boot.tsx'
|
||||
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
|
||||
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
|
||||
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
|
||||
|
||||
@@ -188,6 +188,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clientModuleHost',
|
||||
summary: 'The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'graph(): WebBootGraph',
|
||||
jsDoc: '/**\n * Current composed entry graph (stable object between changes).\n * @returns the graph served as `window.__DSH_BOOT__`.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'clientPath(id: string): string | undefined',
|
||||
jsDoc: '/**\n * Absolute path of an entry\'s client bundle.\n * @param id - entry id (package name).\n * @returns the path, or undefined for an unknown id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'rebuilt(id: string): string | undefined',
|
||||
jsDoc: '/**\n * Re-hash one bundle (the HMR watch\'s registration hook — the only entry\n * point through which bundle content changes reach the graph).\n * @param id - entry id (package name).\n * @returns the new rev, or undefined for an unknown id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'onRebuilt(listener: (id: string, rev: string) => void): () => void',
|
||||
jsDoc: '/**\n * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.\n * @param listener - receives the entry id and its new bundle rev.\n * @returns the unsubscriber.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'onGraphChanged(listener: () => void): () => void',
|
||||
jsDoc: '/**\n * Fires after any flush that recomposed the graph (row added/removed, or a\n * rebuilt rev change). Pull model: listeners re-read {@link graph}.\n * @param listener - notified with no payload.\n * @returns the unsubscriber.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'codeRuntime',
|
||||
summary: 'Registers one `ctx.codeRuntime` implementation.',
|
||||
@@ -314,6 +340,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'httpServer',
|
||||
summary: 'The web-shape HTTP carrier service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register(route: WebRoute): () => void',
|
||||
jsDoc: '/**\n * Register a named route. Duplicate (kind, path) throws — route patterns are\n * a composition-level contract, so a collision is a misconfiguration.\n * @param route - kind, path, and the owning handler.\n * @returns the disposer removing the route.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'tapIndex(transform: (html: string) => string): () => void',
|
||||
jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'invariants',
|
||||
summary: 'Package-owned invariant registry with global and regex-based selection.',
|
||||
@@ -642,6 +682,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
summary: 'The storage hub service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void',
|
||||
jsDoc: '/**\n * Mount a data-form facility on the hub. Mounting is an effect: the\n * returned disposer unmounts the form.\n * @param form - Form key declared in {@link StorageForms}.\n * @param facility - The facility instance to expose.\n * @returns the disposer that unmounts the form.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'form<K extends keyof StorageForms>(form: K): StorageForms[K]',
|
||||
jsDoc: '/**\n * Resolve a mounted data form.\n * @param form - Form key declared in {@link StorageForms}.\n * @returns the mounted facility.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'subagents',
|
||||
summary: 'Named provider registry and capability-checked start surface.',
|
||||
@@ -846,6 +900,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
summary: 'The workspace registry service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'async create(path: string, title?: string): Promise<Workspace>',
|
||||
jsDoc: '/**\n * Create a workspace over an existing directory. The path is canonicalized\n * through `fs.realpath` first — a nonexistent path rejects with the\n * original `ENOENT`, a path resolving to anything but a directory rejects,\n * and a canonical path already owned by another workspace (including a\n * symlink resolving to it) rejects.\n * @param path - Directory the workspace points at; canonicalized before storing.\n * @param title - Display title; defaults to `basename` of the canonical path.\n * @returns the created workspace after durability.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(id: WorkspaceId): Workspace | undefined',
|
||||
jsDoc: '/**\n * Look up a workspace by id.\n * @param id - The workspace id.\n * @returns the workspace, or `undefined` when unknown.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(): Workspace[]',
|
||||
jsDoc: '/**\n * Snapshot of all workspaces, in load-then-creation order.\n * @returns a fresh array of the cached entities.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
|
||||
jsDoc: '/**\n * Resolve a workspace by directory path, through the same `fs.realpath`\n * canon as {@link create} (hence async). A path that does not exist rejects\n * with the original error — a missing directory has no canonical form to\n * compare (a workspace whose recorded directory vanished is only reachable\n * by id; see `Workspace.status`).\n * @param path - Directory path in any spelling (symlinks, `..`, trailing slash).\n * @returns the owning workspace, or `undefined` when none matches.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Every harness event, sorted by name. */
|
||||
@@ -997,6 +1073,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */',
|
||||
summary: 'A command was registered or unregistered.',
|
||||
},
|
||||
{
|
||||
name: 'domain/changed',
|
||||
mode: 'emit',
|
||||
signature: '\'domain/changed\'(change: DomainChanged): void',
|
||||
jsDoc: '/**\n * A domain record or the global singleton changed, emitted once per write\n * strictly after the backend acknowledged durability. Events of one\n * domain arrive in its write-chain order.\n * @param change - domain, table (`\'\'` for global), key (`\'\'` for global),\n * operation discriminant, and on `put` the new snapshot.\n * @mode emit\n */',
|
||||
summary: 'A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability.',
|
||||
},
|
||||
{
|
||||
name: 'fs/edit-intent',
|
||||
mode: 'waterfall',
|
||||
@@ -1999,6 +2082,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SpillSource',
|
||||
declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StorageForms',
|
||||
declaration: 'export interface StorageForms {\n}',
|
||||
},
|
||||
{
|
||||
name: 'StreamChunk',
|
||||
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
|
||||
@@ -2291,6 +2378,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WebFetchResult',
|
||||
declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebRoute',
|
||||
declaration: 'export interface WebRoute {\n kind: WebRouteKind;\n path: string;\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebRouteKind',
|
||||
declaration: 'export type WebRouteKind = \'exact\' | \'prefix\';',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchProvider',
|
||||
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
|
||||
@@ -2335,6 +2430,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WorkflowStopReason',
|
||||
declaration: 'export type WorkflowStopReason = \'completed\' | \'cancelled\' | \'error\';',
|
||||
},
|
||||
{
|
||||
name: 'Workspace',
|
||||
declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
|
||||
},
|
||||
]
|
||||
|
||||
/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-host-apiproxy
|
||||
|
||||
The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`.
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The core spine composition lives in `dsh-host-runtime`.
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
@@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there.
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-apiproxy",
|
||||
"description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime",
|
||||
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -40,12 +40,16 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -11,12 +11,14 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
} from './api/index.ts'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { RpcId } from './api/rpc.ts'
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -170,7 +172,7 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
|
||||
/** Host-level default agent routing (same shape as dsh-host-runtime's HostDefaults, kept structural to avoid a reverse dependency). */
|
||||
export interface ApiProxyDefaults {
|
||||
provider: string
|
||||
model: string
|
||||
@@ -272,8 +274,8 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
|
||||
class SessionNotFound extends Error {}
|
||||
|
||||
/**
|
||||
* Implement ApiProxy over the ctx composed by bootHost.
|
||||
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
|
||||
* Implement ApiProxy over a composed host context.
|
||||
* @param ctx - a context with the host spine mounted (sessions/agents/tools/userInteraction services).
|
||||
* @param defaults - host-level default provider/model: injected as
|
||||
* agentOptions on create/resume, reported by describe from the same source.
|
||||
* @returns the ApiProxy implementation.
|
||||
@@ -1,13 +1,70 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
|
||||
* the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
|
||||
* fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
|
||||
* platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
|
||||
* lives in @deepseek-ai/dsh-host-runtime.
|
||||
* @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
|
||||
* the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
|
||||
* carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
|
||||
* platform subclasses on the client side), and the host-side implementation
|
||||
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
|
||||
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
|
||||
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
|
||||
export type * from './api/index.ts'
|
||||
export { RpcId } from './api/rpc.ts'
|
||||
export { toFetchHandler } from './fetch/handler.ts'
|
||||
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
|
||||
export type { IApiClient } from './fetch/client.ts'
|
||||
export { createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
|
||||
apiProxy: ApiProxy
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* The API gateway service: implements the ApiProxy contract over the composed
|
||||
* host context and provides it as `ctx.apiProxy`. The default project
|
||||
* directory for new sessions is the host process working directory (not a
|
||||
* config field this round).
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = ['agents', 'sessions', 'tools', 'userInteraction']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'apiProxy')
|
||||
const api = createApiProxy(ctx, { provider: config.provider, model: config.model, cwd: process.cwd() })
|
||||
this.sessions = api.sessions
|
||||
this.host = api.host
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
this.respond = api.respond.bind(api)
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiProxyService
|
||||
@@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package is the wire contract layer (types,
|
||||
* schemas, fetch carrier glue) — it emits no cordis events and owns no
|
||||
* mutable cross-plugin relation. rpcId round-trip and schema acceptance are
|
||||
* enforced at the carrier boundary and exercised by the protocol-isomorphism
|
||||
* suite; the live implementation relations belong to dsh-host-runtime.
|
||||
* No runtime invariant: this package is the wire contract layer plus the
|
||||
* host-side gateway over services owned elsewhere — it emits no cordis events
|
||||
* of its own; the session/agent event streams it projects are asserted by
|
||||
* their owning packages' companions. rpcId round-trip and schema acceptance
|
||||
* are enforced at the carrier boundary and exercised by the
|
||||
* protocol-isomorphism suite.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -8,18 +8,33 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-host-runtime
|
||||
|
||||
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
||||
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition).
|
||||
|
||||
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine
|
||||
* composition (bootHost), the ApiProxy implementation (createApiProxy), and
|
||||
* the one-step shell seam (startHost). Host-level configuration (defaults,
|
||||
* persistenceRoot, future user profile) lives here.
|
||||
* composition (bootHost) and the one-step shell seam (startHost). The ApiProxy
|
||||
* implementation lives in @deepseek-ai/dsh-host-apiproxy. Host-level
|
||||
* configuration (defaults, persistenceRoot, future user profile) lives here.
|
||||
*/
|
||||
|
||||
export { bootHost } from './boot.ts'
|
||||
export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts'
|
||||
export { createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
export { startHost } from './start.ts'
|
||||
export type { StartHostOptions, RunningHost } from './start.ts'
|
||||
export { mountWebPlugins } from './web-plugins.ts'
|
||||
@@ -8,10 +8,9 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { bootHost } from './boot.ts'
|
||||
import type { BootHostOptions, HostDefaults } from './boot.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
|
||||
/** Options for startHost. */
|
||||
export interface StartHostOptions {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||
* entry tree over the caller-supplied client plugin roster. The roster is a
|
||||
* composition decision and lives in the composing app (apps/cli); this module
|
||||
* only owns the mount/settle/fail-loud mechanics. The web plugin registry
|
||||
* discovers fetch-arrival entries among the mounted packages by their
|
||||
* package.json dshClient declarations; node halves are empty applies, so
|
||||
* mounting them here costs nothing beyond Loader governance.
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import type { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
/** What the shell hands the web plugin registry (loader view + module resolution seam). */
|
||||
export interface MountedWebPlugins {
|
||||
/** Entry enumeration surface of the mounted Loader (registry scan source). */
|
||||
loader: { entries(): Iterable<{ options: { name: string }; fiber?: unknown; disabled: boolean }> }
|
||||
/** Resolve a plugin package's package.json absolute path. */
|
||||
resolvePkgJson: (name: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the Loader (when absent) and create one in-memory entry per client
|
||||
* plugin package, then wait for the tree to settle. A plugin whose import
|
||||
* fails leaves its entry fiber-less — surfaced here as a loud throw listing
|
||||
* the failures (misconfiguration must not silently drop a client plugin).
|
||||
* @param ctx - host root context (bootHost product).
|
||||
* @param plugins - client plugin package names to mount (the composition layer's roster).
|
||||
* @param anchor - module URL anchoring bare-specifier resolution (the composing
|
||||
* app's import.meta.url; the roster packages must be dependencies of that app).
|
||||
* @returns the loader view and package.json resolver the registry consumes.
|
||||
*/
|
||||
export async function mountWebPlugins(
|
||||
ctx: Context, plugins: readonly string[], anchor: string,
|
||||
): Promise<MountedWebPlugins> {
|
||||
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
||||
// import silently fails and every entry stays fiber-less. The composing app
|
||||
// declares the roster packages as dependencies, so its URL is the right anchor.
|
||||
ctx.baseUrl ??= anchor
|
||||
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
||||
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
||||
for (const name of plugins) {
|
||||
if (!existing.has(name)) await ctx.loader.create({ name })
|
||||
}
|
||||
await ctx.loader.await()
|
||||
const dead = [...ctx.loader.entries()]
|
||||
.filter(entry => plugins.includes(entry.options.name))
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (dead.length > 0) {
|
||||
throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
|
||||
}
|
||||
const require = createRequire(anchor)
|
||||
return {
|
||||
loader: ctx.loader,
|
||||
resolvePkgJson: name => require.resolve(`${name}/package.json`),
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* mountWebPlugins unit coverage (keyless). The Loader-facing behavior —
|
||||
* baseUrl anchoring, entry creation with idempotent reuse, the fiber-less
|
||||
* fail-loud sweep, and the resolver seam — is exercised against a stubbed
|
||||
* loader service so it runs without built lib/ artifacts. The roster is
|
||||
* caller-supplied now (composition moved to apps/cli), so these tests pass
|
||||
* their own lists.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mountWebPlugins } from '../src/web-plugins.ts'
|
||||
|
||||
const ROSTER = [
|
||||
'@deepseek-ai/dsh-plugin-a',
|
||||
'@deepseek-ai/dsh-plugin-b',
|
||||
'@deepseek-ai/dsh-plugin-c',
|
||||
] as const
|
||||
|
||||
interface FakeEntry {
|
||||
options: { name: string }
|
||||
fiber?: unknown
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
|
||||
class FakeLoader {
|
||||
readonly created: string[] = []
|
||||
awaited = 0
|
||||
constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
|
||||
entries(): Iterable<FakeEntry> {
|
||||
return this.entriesList
|
||||
}
|
||||
async create(options: { name: string }): Promise<void> {
|
||||
this.created.push(options.name)
|
||||
this.onCreate?.(options.name)
|
||||
}
|
||||
async await(): Promise<void> {
|
||||
this.awaited += 1
|
||||
}
|
||||
}
|
||||
|
||||
let root: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await root?.fiber.dispose()
|
||||
root = undefined
|
||||
})
|
||||
|
||||
function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
|
||||
root = new Context()
|
||||
const loader = new FakeLoader(entriesList, onCreate)
|
||||
root.reflect.provide('loader', loader)
|
||||
return { ctx: root, loader }
|
||||
}
|
||||
|
||||
describe('mountWebPlugins (stubbed loader)', () => {
|
||||
it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx, loader } = withLoader(entriesList, (name) => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(loader.created).toEqual([...ROSTER])
|
||||
expect(loader.awaited).toBe(1)
|
||||
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER])
|
||||
// The resolver resolves a real package manifest through real module resolution, anchored at this test file.
|
||||
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
|
||||
expect(ctx.baseUrl).toBeDefined()
|
||||
})
|
||||
|
||||
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
|
||||
const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false }))
|
||||
const { ctx, loader } = withLoader(preexisting)
|
||||
await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(loader.created).toEqual([])
|
||||
})
|
||||
|
||||
it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx } = withLoader(entriesList, (name) => {
|
||||
// First one loads; the rest stay fiber-less (import failed silently).
|
||||
entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false })
|
||||
})
|
||||
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url))
|
||||
.rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/)
|
||||
})
|
||||
|
||||
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
|
||||
const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
|
||||
const { ctx } = withLoader(entriesList)
|
||||
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
||||
root = new Context()
|
||||
// An empty roster keeps this keyless and artifact-free: the branch under
|
||||
// test is only the Loader auto-mount.
|
||||
await mountWebPlugins(root, [], import.meta.url)
|
||||
expect(root.get('loader') !== undefined).toBe(true)
|
||||
}, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default
|
||||
|
||||
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx } = withLoader(entriesList, (name) => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
ctx.baseUrl = 'file:///caller/anchor/'
|
||||
await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,16 @@
|
||||
# @deepseek-ai/dsh-host-webserver
|
||||
|
||||
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
|
||||
Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
|
||||
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection.
|
||||
A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
|
||||
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request.
|
||||
None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -20,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
|
||||
- **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
|
||||
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
|
||||
- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
|
||||
- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-webserver",
|
||||
"description": "Web-shape HTTP carrier: static file serving plus the /api/* bridge to an injected fetch-shaped handler (SSE streamed through)",
|
||||
"description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -30,6 +30,9 @@
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
|
||||
@@ -1,232 +1,184 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — the web-shape HTTP carrier: node:http server
|
||||
* routing /api/* to an injected fetch-shaped handler (node:http ↔ WHATWG
|
||||
* bridge with SSE streamed out chunk by chunk) and everything else to static
|
||||
* file serving. Web (browser) shape only — Electron loads dist over file://
|
||||
* and carries fetch over an IPC bridge, not this server. This package never
|
||||
* prints: the URL line belongs to the shell.
|
||||
* @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
|
||||
* node:http server plus the `httpServer` service (named-route registry + index
|
||||
* transform taps + static dist fallback). Knows no harness concepts — every
|
||||
* feature surface (API bridge, plugin bundles, SSE) is a route some other
|
||||
* plugin registers. Web (browser) shape only — Electron loads dist over
|
||||
* file:// and carries fetch over an IPC bridge, not this server. This package
|
||||
* never prints: the URL line belongs to the shell.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { dirname } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { serveStatic } from './static.ts'
|
||||
import { createPluginEventChannel } from './plugin-events.ts'
|
||||
import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts'
|
||||
|
||||
export { createHostWebPluginRegistry } from './web-plugins.ts'
|
||||
export type {
|
||||
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps,
|
||||
} from './web-plugins.ts'
|
||||
export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts'
|
||||
|
||||
/** Options for startWebServer. */
|
||||
export interface WebServerOptions {
|
||||
/** Address or hostname to listen on. */
|
||||
host: string
|
||||
/** Port to listen on; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/**
|
||||
* Absolute path of index.html inside the static root — the caller resolves
|
||||
* it (dist location is workspace knowledge of the shell, not this package's).
|
||||
*/
|
||||
distIndex: string
|
||||
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
|
||||
apiHandler: { fetch: typeof fetch }
|
||||
/**
|
||||
* Web plugin table. When present, every index.html response carries the
|
||||
* `window.__DSH_BOOT__` entry graph script, `/plugins/<id>/client.js` serves
|
||||
* each fetch entry's client bundle, and `GET /plugins/events` streams graph/
|
||||
* rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch
|
||||
* notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only
|
||||
* use).
|
||||
*/
|
||||
webPlugins?: Pick<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: HttpServerService
|
||||
}
|
||||
}
|
||||
|
||||
/** Listening web server handle. */
|
||||
export interface RunningWebServer {
|
||||
/** The listening port, including the OS-assigned value when options.port is zero. */
|
||||
/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
|
||||
export type WebRouteKind = 'exact' | 'prefix'
|
||||
|
||||
/** One named route registration. */
|
||||
export interface WebRoute {
|
||||
kind: WebRouteKind
|
||||
/** Absolute pathname, no trailing slash. */
|
||||
path: string
|
||||
/** Owns the full response lifecycle (may hold the response open, e.g. SSE). */
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** 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
|
||||
/**
|
||||
* Shutdown: close + closeAllConnections (SSE connections never end on their
|
||||
* own; without the force-close, close() would hang). Idempotent.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
/** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
|
||||
distIndex: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the web-shape HTTP server on the caller-selected host and port.
|
||||
* Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else →
|
||||
* static with the step1-locked semantics (403 traversal, SPA fallback 200).
|
||||
* A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a
|
||||
* server error after listen goes to onError. A request whose handling throws
|
||||
* (malformed %-escapes, a client dropping mid-body) is answered 400 — or the
|
||||
* socket destroyed when headers are already out — and reported to onError;
|
||||
* it never becomes an unhandled rejection.
|
||||
* @param options - port, static root anchor, and the API carrier.
|
||||
* @param onError - sink for post-listen server errors and per-request handling failures.
|
||||
* @returns the running server handle once listening.
|
||||
* 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.
|
||||
*/
|
||||
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
|
||||
const { host, port, distIndex, apiHandler, webPlugins } = options
|
||||
const distRoot = dirname(distIndex)
|
||||
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
|
||||
const html = await readFile(distIndex, 'utf8')
|
||||
return injectBootManifest(html, webPlugins.graph())
|
||||
}
|
||||
const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel()
|
||||
// Rebuilt frames come from the registry's own bundle watch (dev mode); a
|
||||
// prod registry without watching simply never notifies.
|
||||
const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined
|
||||
? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) })
|
||||
: undefined
|
||||
export class HttpServerService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
|
||||
port: z.natural().max(65535).required(),
|
||||
distIndex: z.string().required(),
|
||||
})
|
||||
|
||||
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
|
||||
requests; the field is only optional on the client-side IncomingMessage type */
|
||||
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
|
||||
if (rawPath.startsWith('/api/')) {
|
||||
await bridge(req, res, apiHandler)
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') {
|
||||
pluginEvents.connect(res, webPlugins.graph())
|
||||
return
|
||||
}
|
||||
if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
|
||||
await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
|
||||
return
|
||||
}
|
||||
await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
|
||||
private readonly exact = new Map<string, WebRoute>()
|
||||
private readonly prefixes = new Map<string, WebRoute>()
|
||||
private readonly indexTaps: ((html: string) => string)[] = []
|
||||
private readonly distRoot: string
|
||||
private readonly distIndex: string
|
||||
private server!: Server
|
||||
private listenedPort!: number
|
||||
|
||||
constructor(ctx: Context, private config: Config) {
|
||||
super(ctx, 'httpServer')
|
||||
this.distIndex = config.distIndex
|
||||
this.distRoot = dirname(config.distIndex)
|
||||
}
|
||||
// Last-resort guard: handle() rejecting would otherwise be an unhandled
|
||||
// rejection, and one malformed request (a bad %-escape hitting
|
||||
// decodeURIComponent, a client dropping mid-body) would kill the whole
|
||||
// process. Nothing after this catch can throw again on the same response.
|
||||
const server = createServer((req, res) => {
|
||||
handle(req, res).catch((err: unknown) => {
|
||||
onError(err instanceof Error ? err : new Error(String(err)))
|
||||
if (res.headersSent) {
|
||||
res.destroy()
|
||||
|
||||
/** The listening port (the OS-assigned value when config.port is 0). */
|
||||
get port(): number {
|
||||
return this.listenedPort
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const table = route.kind === 'exact' ? this.exact : this.prefixes
|
||||
if (table.has(route.path)) {
|
||||
throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
|
||||
}
|
||||
table.set(route.path, route)
|
||||
return () => { table.delete(route.path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.indexTaps.push(transform)
|
||||
return () => {
|
||||
const at = this.indexTaps.indexOf(transform)
|
||||
if (at !== -1) this.indexTaps.splice(at, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Listen; resolves once the socket is bound (rejection = FAILED fiber). */
|
||||
async [Service.init](): Promise<void> {
|
||||
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
|
||||
requests; the field is only optional on the client-side IncomingMessage type */
|
||||
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
|
||||
const route = this.match(rawPath)
|
||||
if (route !== undefined) {
|
||||
await route.handler(req, res)
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
|
||||
unsubscribeRebuilt?.()
|
||||
server.close(() => { resolveClose() })
|
||||
server.closeAllConnections()
|
||||
}))
|
||||
|
||||
return new Promise((resolveListen, rejectListen) => {
|
||||
server.once('error', rejectListen)
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', rejectListen)
|
||||
server.on('error', onError)
|
||||
resolveListen({ port: (server.address() as AddressInfo).port, close })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
|
||||
* first script in <head> (before the shell bundle reads it). `<` is escaped in
|
||||
* the JSON so plugin-controlled strings cannot break out of the script element.
|
||||
* @param html - the index.html source.
|
||||
* @param graph - the composed entry graph from the registry.
|
||||
* @returns the html with the graph script injected.
|
||||
*/
|
||||
export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
|
||||
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
|
||||
const head = html.indexOf('<head>')
|
||||
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
|
||||
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
|
||||
return `${script}${html}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one plugin client bundle from the registry table (unknown id = 404;
|
||||
* the id may contain a scope slash). The `?rev=` query is a cache-busting
|
||||
* parameter only — serving ignores it; `no-cache` makes the browser revalidate
|
||||
* so a stale rev never sticks.
|
||||
*/
|
||||
async function servePluginBundle(
|
||||
pathname: string, res: ServerResponse, webPlugins: Pick<HostWebPluginRegistry, 'clientPath'>,
|
||||
): Promise<void> {
|
||||
const id = pathname.slice('/plugins/'.length, -'/client.js'.length)
|
||||
const path = webPlugins.clientPath(id)
|
||||
if (path === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path)
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
|
||||
async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
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<void>((resolve) => {
|
||||
const done = (): void => {
|
||||
res.off('drain', done)
|
||||
res.off('close', done)
|
||||
resolve()
|
||||
}
|
||||
res.once('drain', done)
|
||||
res.once('close', done)
|
||||
})
|
||||
// Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405,
|
||||
// traversal 403, miss falls back to index.html 200 (SPA routing).
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex())
|
||||
}
|
||||
// Last-resort guard: handle() rejecting would otherwise be an unhandled
|
||||
// rejection killing the process on one malformed request (bad %-escape,
|
||||
// client dropping mid-body). Per-request failures log and answer 400 —
|
||||
// never a process exit.
|
||||
this.server = createServer((req, res) => {
|
||||
handle(req, res).catch((err: unknown) => {
|
||||
this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
|
||||
if (res.headersSent) {
|
||||
res.destroy()
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.once('error', reject)
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
this.server.off('error', reject)
|
||||
this.server.on('error', (err) => { this.ctx.logger.error(err) })
|
||||
this.listenedPort = (this.server.address() as AddressInfo).port
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
// close + closeAllConnections: held-open responses (SSE) never end on
|
||||
// their own; without the force-close, close() would hang teardown.
|
||||
this.ctx.effect(() => () => new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
this.server.closeAllConnections()
|
||||
}), 'httpServer.listen')
|
||||
}
|
||||
|
||||
/** Longest-prefix-wins over the prefix table after an exact-table miss. */
|
||||
private match(pathname: string): WebRoute | undefined {
|
||||
const exact = this.exact.get(pathname)
|
||||
if (exact !== undefined) return exact
|
||||
let best: WebRoute | undefined
|
||||
for (const [prefix, route] of this.prefixes) {
|
||||
if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue
|
||||
if (best === undefined || prefix.length > best.path.length) best = route
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Index body: dist index.html through the registered taps in order. */
|
||||
private async renderIndex(): Promise<string> {
|
||||
let html = await readFile(this.distIndex, 'utf8')
|
||||
for (const transform of this.indexTaps) html = transform(html)
|
||||
return html
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
|
||||
export default HttpServerService
|
||||
@@ -15,28 +15,30 @@ export const name = 'host-webserver-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: the web plugin registry's boot entry graph must stay
|
||||
* self-consistent — every row must resolve a clientPath under the same id
|
||||
* (the /plugins/<id>/client.js URL it advertises would otherwise 404 on a
|
||||
* browser that just received the graph). Checked synchronously on every
|
||||
* rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read
|
||||
* the same table object, so the relation is self-consistent at any instant —
|
||||
* no need to wait out the registry's own debounced rescan. The registry
|
||||
* arrives through the context key the assembly publishes it under.
|
||||
* Owned relation: route registrations and their disposers must stay
|
||||
* symmetric — after the owning fiber of a registered route unloads, the
|
||||
* route table must no longer answer for its path (a stale route would keep
|
||||
* serving a disposed plugin's handler). Checked on every fiber teardown
|
||||
* (cordis 'internal/plugin'): the service's own registry state is compared
|
||||
* against the set of live fibers' registrations indirectly, by probing that
|
||||
* dispose really removed the entry — the register() disposer contract.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', () => {
|
||||
const registry = ctx.get('webPlugins') as
|
||||
| {
|
||||
graph(): { entries: { id: string; url: string }[] }
|
||||
clientPath(id: string): string | undefined
|
||||
}
|
||||
const server = ctx.get('httpServer') as
|
||||
| { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
|
||||
| undefined
|
||||
if (registry === undefined) return // carrier-only deployments never publish the registry
|
||||
for (const row of registry.graph().entries) {
|
||||
if (registry.clientPath(row.id) === undefined) {
|
||||
fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
|
||||
}
|
||||
if (server === undefined) return // no webserver row in this composition
|
||||
// Register/dispose probe on a reserved path: if dispose leaves the route
|
||||
// behind, a second register throws the duplicate error — the asymmetry.
|
||||
// Each register(probe)() is one register+dispose cycle, so the probe never
|
||||
// leaves residue; a leftover from the first cycle makes the second throw.
|
||||
const probe = { kind: 'exact' as const, path: '/__dsh_invariant_probe__', handler: () => {} }
|
||||
try {
|
||||
server.register(probe)()
|
||||
server.register(probe)()
|
||||
} catch {
|
||||
fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* `/plugins/events` SSE channel: the system-side push surface for the client
|
||||
* entry graph (connect → current graph frame; dev rebuild → rebuilt frame).
|
||||
* Presentation-only wire — frames never enter the session log (distinct from
|
||||
* the /api/* session SSE, which is api-contract territory). Connections are
|
||||
* plain node:http responses held in a set; the server's closeAllConnections
|
||||
* tears them down on shutdown.
|
||||
*/
|
||||
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { WebBootGraph } from './web-plugins.ts'
|
||||
|
||||
/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */
|
||||
export type PluginEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** Broadcast surface owned by the webserver routing layer. */
|
||||
export interface PluginEventChannel {
|
||||
/** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */
|
||||
connect(res: ServerResponse, graph: WebBootGraph): void
|
||||
/** Push one frame to every open connection. */
|
||||
broadcast(frame: PluginEventFrame): void
|
||||
}
|
||||
|
||||
/** Serialize one frame as an SSE data line. */
|
||||
function sseData(frame: PluginEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the channel (one per running server).
|
||||
* @returns the connect/broadcast surface.
|
||||
*/
|
||||
export function createPluginEventChannel(): PluginEventChannel {
|
||||
const connections = new Set<ServerResponse>()
|
||||
return {
|
||||
connect(res, graph) {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
'connection': 'keep-alive',
|
||||
})
|
||||
// Comment line on open so clients/proxies see a live channel even when
|
||||
// no rebuild ever happens; EventSource frame parsing skips it naturally.
|
||||
res.write(': connected\n\n')
|
||||
res.write(sseData({ type: 'graph', graph }))
|
||||
connections.add(res)
|
||||
res.on('close', () => { connections.delete(res) })
|
||||
},
|
||||
broadcast(frame) {
|
||||
const line = sseData(frame)
|
||||
for (const res of connections) res.write(line)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
/**
|
||||
* HostWebPluginRegistry: composes the client entry graph served as
|
||||
* `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the
|
||||
* host Loader's loaded entries by its package.json `dshClient` declaration
|
||||
* (all client plugin packages arrive by fetch — one uniform bundle shape),
|
||||
* resolving each one's client bundle path from `exports["./client"]` and
|
||||
* hashing the bundle content into a `rev` (cache busting + HMR diff anchor).
|
||||
* `inject` edges and the `immediately` prefetch mark come from the manifest
|
||||
* (dshClient — the package owns its dependency edges and its boot tier); the
|
||||
* composition layer contributes only the roster. The webserver consumes the
|
||||
* table to emit the boot graph and to serve `GET /plugins/<id>/client.js`;
|
||||
* in dev mode the registry additionally stat-polls each scanned bundle file
|
||||
* and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild
|
||||
* signal is the registry's own observation — no builder protocol exists).
|
||||
*
|
||||
* The vendored loader emits no "entry loaded" event (only `loader/entry-init`,
|
||||
* which fires at Entry construction before import/apply), so the registry
|
||||
* scans `loader.entries()` and rescans on cordis `internal/plugin` (fiber
|
||||
* create/dispose), microtask-debounced. Plugin-set changes take effect on
|
||||
* restart per the config-source ruling; the subscription only keeps the table
|
||||
* fresh within a process lifetime.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, statSync, type Stats } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/** One composed client entry (`window.__DSH_BOOT__.entries` row). */
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Bundle URL served by this webserver (`/plugins/<id>/client.js?rev=<rev>`). */
|
||||
url: string
|
||||
/** Bundle content hash (sha1, shortened). */
|
||||
rev: string
|
||||
/** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */
|
||||
inject?: string[]
|
||||
/** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over all rows: changes whenever any entry row changes. */
|
||||
rev: string
|
||||
/** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */
|
||||
entries: WebBootEntry[]
|
||||
}
|
||||
|
||||
/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */
|
||||
export interface HostWebPluginRegistry {
|
||||
/** Current composed entry graph (stable object between changes). */
|
||||
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 entry's bundle: updates the row's rev/url and the graph rev.
|
||||
* The dev bundle watch calls this on every observed file change.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the new bundle rev, or undefined for an unknown id.
|
||||
*/
|
||||
rebuilt(id: string): string | undefined
|
||||
/**
|
||||
* Subscribe to bundle rebuilds observed by the dev watch (only fires when
|
||||
* the re-hash produced a different rev — an unchanged bundle is silent).
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
onRebuilt(listener: (id: string, rev: string) => void): () => void
|
||||
/** Remove the loader subscription, all bundle watches, and all rebuild listeners. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Structural view of a loader entry (webserver keeps zero workspace dependencies; cordis stays a type-only peer). */
|
||||
export interface LoaderEntryView {
|
||||
options: { name: string }
|
||||
/** Present once the entry's plugin fiber exists (import succeeded and apply ran/started). */
|
||||
fiber?: unknown
|
||||
/** True when the entry or an owning group is disabled. */
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** Structural view of the host Loader (entry enumeration is all the registry needs). */
|
||||
export interface LoaderView {
|
||||
entries(): Iterable<LoaderEntryView>
|
||||
}
|
||||
|
||||
/** Dependencies injected by the assembly layer. */
|
||||
export interface WebPluginRegistryDeps {
|
||||
/** Host root context; used only to subscribe `internal/plugin` for rescans. */
|
||||
ctx: Context
|
||||
/** The host Loader owning the plugin entries. */
|
||||
loader: LoaderView
|
||||
/**
|
||||
* Resolve a package specifier to its package.json absolute path (assembly
|
||||
* passes `createRequire(...).resolve(`${name}/package.json`)`); injected so
|
||||
* the registry makes no module-resolution assumptions of its own.
|
||||
*/
|
||||
resolvePkgJson: (name: string) => string
|
||||
/** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */
|
||||
onError: (err: Error) => void
|
||||
/**
|
||||
* Dev-mode bundle watching: stat-poll every scanned row's client bundle
|
||||
* with an explicit stat baseline (polling by design: network mounts deliver
|
||||
* no inotify events) and re-hash + notify onRebuilt subscribers on change.
|
||||
* Absent = no watching (prod composition).
|
||||
*/
|
||||
watch?: {
|
||||
/** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */
|
||||
intervalMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
|
||||
interface DshClientDeclaration {
|
||||
inject?: string[]
|
||||
platform: string
|
||||
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
interface WebPluginRecord {
|
||||
entry: WebBootEntry
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
interface WatchedBundle {
|
||||
path: string
|
||||
mtimeMs: number
|
||||
size: number
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
|
||||
function parseDshClient(name: string, value: unknown): DshClientDeclaration | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`web-plugins: ${name} has a non-object dshClient declaration`)
|
||||
}
|
||||
const decl = value as Record<string, unknown>
|
||||
if (typeof decl.platform !== 'string') {
|
||||
throw new Error(`web-plugins: ${name} dshClient.platform must be a string`)
|
||||
}
|
||||
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
|
||||
throw new Error(`web-plugins: ${name} dshClient.inject must be a string array`)
|
||||
}
|
||||
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
|
||||
throw new Error(`web-plugins: ${name} dshClient.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
|
||||
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
|
||||
function clientExportOf(name: string, exportsField: unknown): string | undefined {
|
||||
if (typeof exportsField !== 'object' || exportsField === null) return undefined
|
||||
const client = (exportsField as Record<string, unknown>)['./client']
|
||||
if (client === undefined) return undefined
|
||||
if (typeof client === 'string') return client
|
||||
if (typeof client === 'object' && client !== null) {
|
||||
const fallback = (client as Record<string, unknown>).default
|
||||
if (typeof fallback === 'string') return fallback
|
||||
}
|
||||
throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`)
|
||||
}
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
|
||||
function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry {
|
||||
return {
|
||||
id,
|
||||
url: `/plugins/${id}/client.js?rev=${rev}`,
|
||||
rev,
|
||||
...(inject !== undefined ? { inject } : {}),
|
||||
...(immediately ? { immediately: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose the graph value from the current table. */
|
||||
function composeGraph(table: Map<string, WebPluginRecord>): WebBootGraph {
|
||||
const entries = [...table.values()].map(record => record.entry)
|
||||
return { rev: shortHash(JSON.stringify(entries)), entries }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the web plugin registry: scan once synchronously (a malformed
|
||||
* declaration, an unbuilt bundle, or an invalid watch interval throws here —
|
||||
* load-time fail loud), then rescan on `internal/plugin`, microtask-debounced
|
||||
* (failures go to `deps.onError`). With `deps.watch`, every scanned bundle
|
||||
* file is stat-polled and a content change re-hashes the row and notifies
|
||||
* `onRebuilt` subscribers.
|
||||
* @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}).
|
||||
* @returns the registry handle.
|
||||
*/
|
||||
export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry {
|
||||
const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500
|
||||
if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) {
|
||||
throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`)
|
||||
}
|
||||
|
||||
const stageWatches = (
|
||||
candidateTable: Map<string, WebPluginRecord>,
|
||||
currentWatches: Map<string, WatchedBundle>,
|
||||
): Map<string, WatchedBundle> => {
|
||||
const candidateWatches = new Map<string, WatchedBundle>()
|
||||
if (watchInterval === undefined) return candidateWatches
|
||||
for (const [id, record] of candidateTable) {
|
||||
const current = currentWatches.get(id)
|
||||
if (current?.path === record.clientPath) {
|
||||
candidateWatches.set(id, { ...current })
|
||||
continue
|
||||
}
|
||||
const baseline = statSync(record.clientPath)
|
||||
candidateWatches.set(id, {
|
||||
path: record.clientPath,
|
||||
mtimeMs: baseline.mtimeMs,
|
||||
size: baseline.size,
|
||||
dirty: false,
|
||||
})
|
||||
}
|
||||
return candidateWatches
|
||||
}
|
||||
|
||||
let table = scan(deps)
|
||||
let graph = composeGraph(table)
|
||||
let watched = stageWatches(table, new Map())
|
||||
const rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
|
||||
const rebuilt = (id: string): string | undefined => {
|
||||
const record = table.get(id)
|
||||
if (record === undefined) return undefined
|
||||
const rev = shortHash(readFileSync(record.clientPath))
|
||||
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
|
||||
graph = composeGraph(table)
|
||||
return rev
|
||||
}
|
||||
|
||||
// Dev bundle watch: capture every row's baseline synchronously before the
|
||||
// registry is returned, then poll those baselines. fs.watchFile establishes
|
||||
// its first baseline asynchronously, so an immediate rebuild can otherwise
|
||||
// become the baseline and disappear without an observed delta.
|
||||
const pollWatches = (): void => {
|
||||
for (const [id, watch] of watched) {
|
||||
let current: Stats
|
||||
try {
|
||||
current = statSync(watch.path)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') {
|
||||
watch.dirty = true
|
||||
continue
|
||||
}
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
continue
|
||||
}
|
||||
if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
|
||||
const before = table.get(id)?.entry.rev
|
||||
let rev: string | undefined
|
||||
try {
|
||||
rev = rebuilt(id)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') {
|
||||
watch.dirty = true
|
||||
continue
|
||||
}
|
||||
watch.mtimeMs = current.mtimeMs
|
||||
watch.size = current.size
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
continue
|
||||
}
|
||||
watch.mtimeMs = current.mtimeMs
|
||||
watch.size = current.size
|
||||
watch.dirty = false
|
||||
if (rev === undefined || rev === before) continue
|
||||
for (const notify of rebuildListeners) {
|
||||
// A throwing subscriber must not skip later subscribers or escape the
|
||||
// polling callback into the process event loop.
|
||||
try {
|
||||
notify(id, rev)
|
||||
} catch (error) {
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval)
|
||||
watchTimer?.unref()
|
||||
|
||||
let pending = false
|
||||
const unsubscribe = deps.ctx.on('internal/plugin', () => {
|
||||
if (pending) return
|
||||
pending = true
|
||||
queueMicrotask(() => {
|
||||
pending = false
|
||||
try {
|
||||
const candidateTable = scan(deps)
|
||||
const candidateGraph = composeGraph(candidateTable)
|
||||
const candidateWatches = stageWatches(candidateTable, watched)
|
||||
table = candidateTable
|
||||
graph = candidateGraph
|
||||
watched = candidateWatches
|
||||
} catch (error) {
|
||||
// Keep serving the previous graph: a mid-flight rescan failure must not
|
||||
// take down the boot manifest for plugins that were fine.
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
graph: () => graph,
|
||||
clientPath: id => table.get(id)?.clientPath,
|
||||
rebuilt,
|
||||
onRebuilt: (listener) => {
|
||||
rebuildListeners.add(listener)
|
||||
return () => { rebuildListeners.delete(listener) }
|
||||
},
|
||||
dispose: () => {
|
||||
unsubscribe()
|
||||
if (watchTimer !== undefined) clearInterval(watchTimer)
|
||||
watched.clear()
|
||||
rebuildListeners.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */
|
||||
function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
|
||||
const table = new Map<string, WebPluginRecord>()
|
||||
for (const entry of deps.loader.entries()) {
|
||||
if (entry.fiber === undefined || entry.disabled) continue
|
||||
const name = entry.options.name
|
||||
if (table.has(name)) continue
|
||||
const pkgPath = deps.resolvePkgJson(name)
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
|
||||
const decl = parseDshClient(name, pkg.dshClient)
|
||||
if (decl === undefined || decl.platform !== 'web') continue
|
||||
const clientRel = clientExportOf(name, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`)
|
||||
}
|
||||
const clientPath = join(dirname(pkgPath), clientRel)
|
||||
const rev = shortHash(readFileSync(clientPath))
|
||||
table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath })
|
||||
}
|
||||
return table
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Webserver invariant companion: the boot-graph consistency audit — every
|
||||
* fetch-arrival graph row must resolve a clientPath, checked on fiber
|
||||
* lifecycle events against the assembly-published 'webPlugins' context key.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as WebserverInvariant from '../src/invariant.ts'
|
||||
|
||||
interface RegistryStub {
|
||||
graph(): { entries: { id: string; url: string }[] }
|
||||
clientPath(id: string): string | undefined
|
||||
}
|
||||
|
||||
async function setup(registry?: RegistryStub): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(WebserverInvariant).await()
|
||||
if (registry !== undefined) ctx.reflect.provide('webPlugins', registry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Fire the audit trigger directly (same technique as the scope invariant
|
||||
* spec): a synchronous emit propagates the fail() throw to the caller. */
|
||||
function trigger(ctx: Context): void {
|
||||
;(ctx.emit as (event: string, ...args: unknown[]) => void)('internal/plugin', ctx.fiber)
|
||||
}
|
||||
|
||||
describe('webserver manifest invariant', () => {
|
||||
it('stays silent without a registry (carrier-only deployment) and with a consistent table', async () => {
|
||||
const bare = await setup()
|
||||
expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published
|
||||
|
||||
const consistent = await setup({
|
||||
graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }),
|
||||
clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined,
|
||||
})
|
||||
expect(() => { trigger(consistent) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws on a graph row whose bundle path no longer resolves', async () => {
|
||||
const ctx = await setup({
|
||||
graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }),
|
||||
clientPath: () => undefined,
|
||||
})
|
||||
expect(() => { trigger(ctx) })
|
||||
.toThrow(/graph row "ghost".*resolves no client bundle path/)
|
||||
})
|
||||
})
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
statSync,
|
||||
type PathLike,
|
||||
type Stats,
|
||||
unlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts'
|
||||
import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts'
|
||||
|
||||
const fsControl = vi.hoisted(() => ({ failNextStatPath: undefined as string | undefined }))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
statSync: (path: PathLike): Stats => {
|
||||
if (String(path) === fsControl.failNextStatPath) {
|
||||
fsControl.failNextStatPath = undefined
|
||||
throw Object.assign(new Error('staged bundle missing'), { code: 'ENOENT' })
|
||||
}
|
||||
return actual.statSync(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fsControl.failNextStatPath = undefined
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */
|
||||
function makePkg(root: string, name: string, pkg: Record<string, unknown>, withBundle = true): string {
|
||||
const dir = join(root, name.replaceAll('/', '__'))
|
||||
mkdirSync(join(dir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg }))
|
||||
if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`)
|
||||
return join(dir, 'package.json')
|
||||
}
|
||||
|
||||
const webDecl = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
dshClient: { inject: [], platform: 'web', ...extra },
|
||||
exports: { '.': './lib/index.js', './client': './lib/client.js' },
|
||||
})
|
||||
|
||||
interface Fixture {
|
||||
deps: WebPluginRegistryDeps
|
||||
entries: LoaderEntryView[]
|
||||
errors: Error[]
|
||||
ctx: Context
|
||||
root: string
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
specs: { name: string; pkg: Record<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
|
||||
): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
|
||||
const paths = new Map<string, string>()
|
||||
const entries: LoaderEntryView[] = specs.map((spec) => {
|
||||
paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true))
|
||||
return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false }
|
||||
})
|
||||
const ctx = new Context()
|
||||
const errors: Error[] = []
|
||||
const deps: WebPluginRegistryDeps = {
|
||||
ctx,
|
||||
loader: { entries: () => entries },
|
||||
resolvePkgJson: (name) => {
|
||||
const path = paths.get(name)
|
||||
if (path === undefined) throw new Error(`unresolvable ${name}`)
|
||||
return path
|
||||
},
|
||||
onError: err => void errors.push(err),
|
||||
}
|
||||
return { deps, entries, errors, ctx, root }
|
||||
}
|
||||
|
||||
describe('createHostWebPluginRegistry', () => {
|
||||
it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => {
|
||||
const { deps } = makeDeps([
|
||||
{ name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) },
|
||||
{ name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) },
|
||||
{ name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const graph = registry.graph()
|
||||
expect(graph.rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
const connection = graph.entries[0]
|
||||
expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection')
|
||||
expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`)
|
||||
expect(connection?.immediately).toBe(true)
|
||||
const layout = graph.entries[1]
|
||||
expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout')
|
||||
expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime'])
|
||||
expect(layout?.immediately).toBeUndefined()
|
||||
expect(graph.entries).toHaveLength(2)
|
||||
expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/)
|
||||
expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined()
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('skips entries that are unloaded, disabled, or declare another platform', () => {
|
||||
const { deps } = makeDeps([
|
||||
{ name: 'not-loaded', pkg: webDecl(), loaded: false },
|
||||
{ name: 'disabled', pkg: webDecl(), disabled: true },
|
||||
{ name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } },
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.graph().entries).toEqual([])
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('fails loud at build time on a dshClient declaration without a "./client" export', () => {
|
||||
const { deps } = makeDeps([
|
||||
{ name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } },
|
||||
])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
|
||||
})
|
||||
|
||||
it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => {
|
||||
const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('fails loud on malformed declaration fields', () => {
|
||||
for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) {
|
||||
const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => {
|
||||
const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const before = registry.graph()
|
||||
const beforeRow = before.entries.find(e => e.id === 'hot')
|
||||
writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents')
|
||||
const rev = registry.rebuilt('hot')
|
||||
expect(rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
expect(rev).not.toBe(beforeRow?.rev)
|
||||
const after = registry.graph()
|
||||
const afterRow = after.entries.find(e => e.id === 'hot')
|
||||
expect(afterRow?.rev).toBe(rev)
|
||||
expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`)
|
||||
expect(afterRow?.immediately).toBe(true)
|
||||
expect(after.rev).not.toBe(before.rev)
|
||||
// Unknown ids are not rebuildable.
|
||||
expect(registry.rebuilt('nope')).toBeUndefined()
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => {
|
||||
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
|
||||
deps.watch = { intervalMs: 20 }
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const before = registry.graph().entries[0]?.rev
|
||||
const rebuilds: { id: string; rev: string }[] = []
|
||||
registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
|
||||
|
||||
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents')
|
||||
await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 })
|
||||
expect(rebuilds[0]?.id).toBe('watched')
|
||||
expect(rebuilds[0]?.rev).not.toBe(before)
|
||||
expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
|
||||
|
||||
registry.dispose()
|
||||
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents')
|
||||
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
||||
expect(rebuilds).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('watch mode: a failed rescan baseline preserves the published table and graph', async () => {
|
||||
const { deps, entries, errors, ctx, root } = makeDeps([
|
||||
{ name: 'stable', pkg: webDecl() },
|
||||
{ name: 'late', pkg: webDecl(), loaded: false },
|
||||
])
|
||||
deps.watch = { intervalMs: 1_000 }
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const before = registry.graph()
|
||||
|
||||
;(entries[1] as { fiber?: unknown }).fiber = {}
|
||||
fsControl.failNextStatPath = join(root, 'late', 'lib', 'client.js')
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(errors[0]?.message).toContain('staged bundle missing')
|
||||
expect(registry.graph()).toBe(before)
|
||||
expect(registry.clientPath('late')).toBeUndefined()
|
||||
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(registry.graph().entries.map(row => row.id)).toEqual(['stable', 'late'])
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('watch mode: a missing bundle forces a re-hash when identical metadata reappears', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
|
||||
const bundle = join(root, 'watched', 'lib', 'client.js')
|
||||
const fixedTime = new Date(1_600_000_000_000)
|
||||
utimesSync(bundle, fixedTime, fixedTime)
|
||||
deps.watch = { intervalMs: 20 }
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const baseline = statSync(bundle)
|
||||
const rebuilds: { id: string; rev: string }[] = []
|
||||
registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
|
||||
|
||||
unlinkSync(bundle)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
writeFileSync(bundle, 'x'.repeat(baseline.size))
|
||||
utimesSync(bundle, fixedTime, fixedTime)
|
||||
const restored = statSync(bundle)
|
||||
expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
|
||||
mtimeMs: baseline.mtimeMs,
|
||||
size: baseline.size,
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
|
||||
expect(rebuilds).toHaveLength(1)
|
||||
expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('rejects a non-positive or non-integer watch interval at build time', () => {
|
||||
for (const intervalMs of [0, -5, 1.5]) {
|
||||
const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }])
|
||||
deps.watch = { intervalMs }
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => {
|
||||
const { deps, entries, errors, ctx } = makeDeps([
|
||||
{ name: 'late-loader', pkg: webDecl(), loaded: false },
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.graph().entries).toEqual([])
|
||||
|
||||
// Entry finishes loading; a fiber lifecycle event triggers the debounced rescan.
|
||||
;(entries[0] as { fiber?: unknown }).fiber = {}
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan
|
||||
await Promise.resolve()
|
||||
expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
|
||||
|
||||
// A failing rescan reports the error and keeps serving the previous graph.
|
||||
entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false })
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
|
||||
|
||||
registry.dispose()
|
||||
entries.pop()
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(errors).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('injectBootManifest', () => {
|
||||
it('injects the graph as the first script inside <head> and escapes </script> breakouts', () => {
|
||||
const html = '<html><head><script src="app.js"></script></head><body></body></html>'
|
||||
const out = injectBootManifest(html, {
|
||||
rev: 'r1',
|
||||
entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
|
||||
})
|
||||
expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
|
||||
expect(out).not.toContain('</script><script>alert(1)')
|
||||
expect(out).toContain('\\u003c/script')
|
||||
})
|
||||
|
||||
it('prepends when the page has no <head>', () => {
|
||||
const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
|
||||
expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientExportOf shapes (through the registry build)', () => {
|
||||
it('accepts the conditional {types, default} export form', () => {
|
||||
const { deps } = makeDeps([{
|
||||
name: 'conditional',
|
||||
pkg: {
|
||||
dshClient: { platform: 'web' },
|
||||
exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
|
||||
},
|
||||
}])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
|
||||
for (const exportsField of [
|
||||
{ './client': { types: './x.d.ts' } },
|
||||
{ './client': ['./a.js'] },
|
||||
]) {
|
||||
const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
|
||||
}
|
||||
// Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
|
||||
const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
|
||||
})
|
||||
|
||||
it('skips duplicate loader entries for the same package name (first wins)', () => {
|
||||
const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
|
||||
const first = entries[0] as LoaderEntryView
|
||||
entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
|
||||
void first
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
|
||||
// client: null → the object-form branch's null guard.
|
||||
const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
|
||||
expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
|
||||
|
||||
// Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
|
||||
const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
|
||||
const original = deps.resolvePkgJson
|
||||
deps.resolvePkgJson = (name) => {
|
||||
|
||||
if (name === 'ghost-two') throw 'string failure'
|
||||
return original(name)
|
||||
}
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(errors[0]).toBeInstanceOf(Error)
|
||||
expect(String(errors[0])).toContain('string failure')
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,400 +0,0 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { Server as NetServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startWebServer, type RunningWebServer } from '../src/index.ts'
|
||||
|
||||
/** dist fixture: index.html + one asset of each MIME class + a subdir. */
|
||||
function makeDist(): { distIndex: string; distRoot: string } {
|
||||
const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
|
||||
writeFileSync(join(distRoot, 'index.html'), '<html>INDEX</html>')
|
||||
writeFileSync(join(distRoot, 'app.js'), 'console.log(1)')
|
||||
writeFileSync(join(distRoot, 'app.css'), 'body{}')
|
||||
writeFileSync(join(distRoot, 'logo.svg'), '<svg/>')
|
||||
writeFileSync(join(distRoot, 'data.json'), '{}')
|
||||
writeFileSync(join(distRoot, 'app.js.map'), '{}')
|
||||
writeFileSync(join(distRoot, 'blob.bin'), 'BIN')
|
||||
mkdirSync(join(distRoot, 'sub'))
|
||||
writeFileSync(join(distRoot, 'sub', 'page.html'), '<html>SUB</html>')
|
||||
return { distIndex: join(distRoot, 'index.html'), distRoot }
|
||||
}
|
||||
|
||||
const echoingApi = {
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const req = input instanceof Request ? input : new Request(input, init)
|
||||
if (req.url.endsWith('/api/echo')) {
|
||||
return Response.json({ method: req.method, body: await req.text(), header: req.headers.get('x-probe') })
|
||||
}
|
||||
if (req.url.endsWith('/api/empty')) return new Response(null, { status: 204 })
|
||||
if (req.url.endsWith('/api/big')) {
|
||||
// Chunks far above any socket highWaterMark force res.write to return false.
|
||||
const big = new Uint8Array(4 * 1024 * 1024).fill(65)
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(big)
|
||||
controller.enqueue(big)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } })
|
||||
}
|
||||
if (req.url.endsWith('/api/sse')) {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: one\n\n'))
|
||||
controller.enqueue(encoder.encode('data: two\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
if (req.url.endsWith('/api/throw-string')) {
|
||||
// Non-Error rejection: the guard must wrap it for onError.
|
||||
throw 'string failure'
|
||||
}
|
||||
if (req.url.endsWith('/api/explode-mid-stream')) {
|
||||
// Headers go out with the first chunk, then the source errors: the
|
||||
// guard's headersSent leg must destroy the socket, not writeHead again.
|
||||
// The error is deferred a tick so the 200 + first chunk actually flush
|
||||
// to the client before the teardown.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('data: first\n\n'))
|
||||
setTimeout(() => { controller.error(new Error('stream exploded')) }, 20)
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
if (req.url.endsWith('/api/abort-probe')) {
|
||||
// Endless SSE that only ends when the request signal aborts.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
req.signal.addEventListener('abort', () => {
|
||||
try {
|
||||
controller.close()
|
||||
} catch { /* already closed by teardown: nothing else can reach this */ }
|
||||
}, { once: true })
|
||||
controller.enqueue(new TextEncoder().encode('data: open\n\n'))
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
return new Response('nope', { status: 404 })
|
||||
},
|
||||
}
|
||||
|
||||
let server: RunningWebServer | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close()
|
||||
server = undefined
|
||||
})
|
||||
|
||||
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
|
||||
describe('startWebServer', () => {
|
||||
it('reports the listening port and closes idempotently', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
expect(server.port).toBeGreaterThan(0)
|
||||
const first = server.close()
|
||||
const second = server.close()
|
||||
expect(second).toBe(first)
|
||||
await first
|
||||
server = undefined
|
||||
})
|
||||
|
||||
it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => {
|
||||
const { distIndex } = makeDist()
|
||||
const port = 3080
|
||||
const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function (
|
||||
this: NetServer, ...args: unknown[]
|
||||
): NetServer {
|
||||
const callback = args.at(-1)
|
||||
if (typeof callback !== 'function') throw new TypeError('listen callback missing')
|
||||
queueMicrotask(callback as () => void)
|
||||
return this
|
||||
})
|
||||
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
|
||||
try {
|
||||
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
|
||||
await inertServer.close()
|
||||
} finally {
|
||||
address.mockRestore()
|
||||
listen.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects when the port is already taken', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
const { port } = server
|
||||
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
|
||||
.rejects.toMatchObject({ code: 'EADDRINUSE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('static serving', () => {
|
||||
it('serves index at /, subpaths by MIME, octet-stream for unknown, SPA fallback on miss', async () => {
|
||||
const base = await boot()
|
||||
const index = await fetch(`${base}/`)
|
||||
expect(index.status).toBe(200)
|
||||
expect(index.headers.get('content-type')).toBe('text/html; charset=utf-8')
|
||||
expect(await index.text()).toBe('<html>INDEX</html>')
|
||||
|
||||
expect((await fetch(`${base}/app.js`)).headers.get('content-type')).toBe('text/javascript; charset=utf-8')
|
||||
expect((await fetch(`${base}/app.css`)).headers.get('content-type')).toBe('text/css; charset=utf-8')
|
||||
expect((await fetch(`${base}/logo.svg`)).headers.get('content-type')).toBe('image/svg+xml')
|
||||
expect((await fetch(`${base}/data.json`)).headers.get('content-type')).toBe('application/json')
|
||||
expect((await fetch(`${base}/app.js.map`)).headers.get('content-type')).toBe('application/json')
|
||||
expect((await fetch(`${base}/blob.bin`)).headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(await (await fetch(`${base}/sub/page.html`)).text()).toBe('<html>SUB</html>')
|
||||
|
||||
const miss = await fetch(`${base}/routes/deep/link`)
|
||||
expect(miss.status).toBe(200)
|
||||
expect(await miss.text()).toBe('<html>INDEX</html>')
|
||||
})
|
||||
|
||||
it('403s traversal outside the dist root and 405s non-GET/HEAD', async () => {
|
||||
const base = await boot()
|
||||
// %2e%2e would be dot-collapsed by WHATWG URL parsing on both ends; an
|
||||
// encoded slash keeps the segment intact until the server's decodeURIComponent.
|
||||
const traversal = await fetch(`${base}/..%2f..%2fetc%2fpasswd`)
|
||||
expect(traversal.status).toBe(403)
|
||||
const put = await fetch(`${base}/index.html`, { method: 'PUT', body: 'x' })
|
||||
expect(put.status).toBe(405)
|
||||
})
|
||||
|
||||
it('answers HEAD like GET (no 405)', async () => {
|
||||
const base = await boot()
|
||||
const head = await fetch(`${base}/`, { method: 'HEAD' })
|
||||
expect(head.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint + events channel)', () => {
|
||||
const FETCH_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
const graphValue = {
|
||||
rev: 'graphrev00001',
|
||||
entries: [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=eeee2222ffff', rev: 'eeee2222ffff', immediately: true },
|
||||
{ id: FETCH_ID, url: `/plugins/${FETCH_ID}/client.js?rev=aaaa0000bbbb`, rev: 'aaaa0000bbbb', inject: [] },
|
||||
],
|
||||
}
|
||||
|
||||
/** Captures the server's onRebuilt subscription so tests can fire registry notifications by hand. */
|
||||
interface RebuiltHarness {
|
||||
notify: (id: string, rev: string) => void
|
||||
unsubscribed: boolean
|
||||
}
|
||||
|
||||
async function bootWithPlugins(harness?: RebuiltHarness): Promise<string> {
|
||||
const { distIndex, distRoot } = makeDist()
|
||||
writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
|
||||
const webPlugins = {
|
||||
graph: () => graphValue,
|
||||
clientPath: (id: string) => id === FETCH_ID ? join(distRoot, 'bundle.js') : undefined,
|
||||
onRebuilt: (listener: (id: string, rev: string) => void) => {
|
||||
if (harness !== undefined) harness.notify = listener
|
||||
return () => {
|
||||
if (harness !== undefined) harness.unsubscribed = true
|
||||
}
|
||||
},
|
||||
}
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
|
||||
it('injects the window.__DSH_BOOT__ graph into / and SPA fallbacks; asset requests stay verbatim', async () => {
|
||||
const base = await bootWithPlugins()
|
||||
const index = await (await fetch(`${base}/`)).text()
|
||||
expect(index).toContain('window.__DSH_BOOT__')
|
||||
const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
|
||||
expect(JSON.parse(manifest ?? '')).toEqual(graphValue)
|
||||
|
||||
const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
|
||||
expect(fallback).toContain('window.__DSH_BOOT__')
|
||||
const direct = await (await fetch(`${base}/index.html`)).text()
|
||||
expect(direct).toContain('window.__DSH_BOOT__')
|
||||
|
||||
expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
|
||||
})
|
||||
|
||||
it('serves registered client bundles with no-cache (rev query ignored) and 404s unknown ids (no SPA fallback)', async () => {
|
||||
const base = await bootWithPlugins()
|
||||
const bundle = await fetch(`${base}/plugins/${FETCH_ID}/client.js?rev=whatever`)
|
||||
expect(bundle.status).toBe(200)
|
||||
expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
|
||||
expect(bundle.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(await bundle.text()).toContain('DSHClientProxy')
|
||||
|
||||
expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
const webPlugins = {
|
||||
graph: () => graphValue,
|
||||
clientPath: () => '/nonexistent/lib/client.js',
|
||||
onRebuilt: () => () => undefined,
|
||||
}
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
)
|
||||
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/${FETCH_ID}/client.js`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('keeps all plugin surfaces off without the webPlugins option', async () => {
|
||||
const base = await boot()
|
||||
expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
|
||||
// No plugin routes: fall through to static SPA fallback semantics.
|
||||
const res = await fetch(`${base}/plugins/x/client.js`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('<html>INDEX</html>')
|
||||
const events = await fetch(`${base}/plugins/events`)
|
||||
expect(await events.text()).toBe('<html>INDEX</html>')
|
||||
})
|
||||
|
||||
it('GET /plugins/events opens SSE with the current graph frame; a registry rebuild notification broadcasts', async () => {
|
||||
const harness: RebuiltHarness = { notify: () => { throw new Error('onRebuilt never subscribed') }, unsubscribed: false }
|
||||
const base = await bootWithPlugins(harness)
|
||||
const events = await fetch(`${base}/plugins/events`)
|
||||
expect(events.status).toBe(200)
|
||||
expect(events.headers.get('content-type')).toBe('text/event-stream')
|
||||
const reader = events.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
async function readUntil(marker: string): Promise<void> {
|
||||
while (!buffer.includes(marker)) {
|
||||
const chunk = await reader?.read()
|
||||
if (chunk?.done !== false) throw new Error('SSE stream ended early')
|
||||
buffer += decoder.decode(chunk.value, { stream: true })
|
||||
}
|
||||
}
|
||||
await readUntil('"type":"graph"')
|
||||
expect(buffer).toContain(': connected')
|
||||
const graphLine = /data: (.*)\n\n/.exec(buffer)?.[1]
|
||||
expect(JSON.parse(graphLine ?? '')).toEqual({ type: 'graph', graph: graphValue })
|
||||
|
||||
// The registry's bundle watch observed a rebuild: the server relays it as an SSE frame.
|
||||
harness.notify(FETCH_ID, 'cccc1111dddd')
|
||||
await readUntil('"type":"rebuilt"')
|
||||
expect(buffer).toContain(JSON.stringify({ type: 'rebuilt', id: FETCH_ID, rev: 'cccc1111dddd' }))
|
||||
await reader?.cancel()
|
||||
|
||||
// Shutdown unsubscribes the relay (no broadcast into a closed channel).
|
||||
await server?.close()
|
||||
server = undefined
|
||||
expect(harness.unsubscribed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request-handling guard (one bad request must not kill the process)', () => {
|
||||
it('400s malformed %-escapes, reports to onError, and stays alive', async () => {
|
||||
const errors: Error[] = []
|
||||
const base = await boot(err => errors.push(err))
|
||||
for (const path of ['/%', '/%c0', '/%zz%']) {
|
||||
expect((await fetch(`${base}${path}`)).status).toBe(400)
|
||||
}
|
||||
expect(errors.length).toBe(3)
|
||||
expect(errors[0]?.name).toBe('URIError')
|
||||
// The barrage left the server serving.
|
||||
expect((await fetch(`${base}/`)).status).toBe(200)
|
||||
})
|
||||
|
||||
it('wraps a non-Error throw for onError and still answers 400', async () => {
|
||||
const errors: Error[] = []
|
||||
const base = await boot(err => errors.push(err))
|
||||
expect((await fetch(`${base}/api/throw-string`, { method: 'POST' })).status).toBe(400)
|
||||
expect(errors[0]).toBeInstanceOf(Error)
|
||||
expect(errors[0]?.message).toBe('string failure')
|
||||
})
|
||||
|
||||
it('destroys the socket when the failure lands after headers went out', async () => {
|
||||
const errors: Error[] = []
|
||||
const base = await boot(err => errors.push(err))
|
||||
const response = await fetch(`${base}/api/explode-mid-stream`)
|
||||
expect(response.status).toBe(200) // headers made it out before the explosion
|
||||
await expect(response.text()).rejects.toThrow() // then the socket is torn down
|
||||
expect(errors.length).toBe(1)
|
||||
expect((await fetch(`${base}/`)).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('/api bridge', () => {
|
||||
it('forwards method, headers, and body; relays status and body back', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/echo`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-probe': 'p1' },
|
||||
body: JSON.stringify({ n: 1 }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
|
||||
})
|
||||
|
||||
it('relays a bodyless response', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/empty`, { method: 'POST' })
|
||||
expect(response.status).toBe(204)
|
||||
expect(await response.text()).toBe('')
|
||||
})
|
||||
|
||||
it('streams SSE frames through chunk by chunk', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/sse`)
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream')
|
||||
expect(await response.text()).toBe('data: one\n\ndata: two\n\n')
|
||||
})
|
||||
|
||||
it('waits for drain when a streamed chunk overfills the socket buffer', async () => {
|
||||
// 4 MiB chunks dwarf the socket highWaterMark, so res.write returns false
|
||||
// and the bridge parks on 'drain'; reading the body to completion proves
|
||||
// the loop resumed instead of dropping the remainder.
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/big`)
|
||||
const body = new Uint8Array(await response.arrayBuffer())
|
||||
expect(body.length).toBe(8 * 1024 * 1024)
|
||||
expect(body[0]).toBe(65)
|
||||
expect(body[body.length - 1]).toBe(65)
|
||||
})
|
||||
|
||||
it('releases a drain wait when the client disconnects mid-chunk', async () => {
|
||||
// The 'close' leg of the drain race: abort while the socket buffer is
|
||||
// still full so the parked write wakes via 'close', not 'drain'.
|
||||
const base = await boot()
|
||||
const ac = new AbortController()
|
||||
const response = await fetch(`${base}/api/big`, { signal: ac.signal })
|
||||
const reader = response.body?.getReader()
|
||||
const first = await reader?.read()
|
||||
expect(first?.value?.length).toBeGreaterThan(0)
|
||||
ac.abort()
|
||||
// afterEach close() completing is the leak assertion, same as abort-probe.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
||||
})
|
||||
|
||||
it('aborts the bridged request when the client disconnects mid-SSE', async () => {
|
||||
const base = await boot()
|
||||
const ac = new AbortController()
|
||||
const response = await fetch(`${base}/api/abort-probe`, { signal: ac.signal })
|
||||
const reader = response.body?.getReader()
|
||||
expect(reader).toBeDefined()
|
||||
const first = await reader?.read()
|
||||
expect(new TextDecoder().decode(first?.value)).toContain('open')
|
||||
ac.abort()
|
||||
// server-side abort propagation has no client-observable handshake beyond
|
||||
// the closed connection; close() would hang on a leaked live SSE socket,
|
||||
// so afterEach completing IS the assertion that the bridge released it.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# storage/ — non-session storage family
|
||||
|
||||
The storage family persists everything that is not a session event log: a hub where named backends and typed data forms meet. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `storage/` | The hub: named backend registry + merge-extensible data-form mounts, backend facet vocabulary, shared conformance suite | `ctx.storage` |
|
||||
| `storage-json/` | JSON backend: one human-readable file per unit, atomic whole-file rewrite | registers backend `json` |
|
||||
| `storage-sqlite/` | SQLite backend: one database hosting all routed units, document-per-row | registers backend `sqlite` |
|
||||
| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | mounts `ctx.storage.domain` |
|
||||
|
||||
Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Consumers never touch backends directly — they open declared domains through the domain form.
|
||||
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-storage-domain
|
||||
|
||||
Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility.
|
||||
|
||||
Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
| key | meaning |
|
||||
| --- | --- |
|
||||
| `backend` | Default backend name for every domain (required; no universally correct medium exists). |
|
||||
| `routes` | Per-domain overrides: domain name → backend name. |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Durable domain state
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. The package registers no tools, injects no prompts, and appends no session events; it stores non-session data (workspace records, future session sidecars) behind `ctx.storage.domain` and emits only the in-process `domain/changed` event, which reaches a model only if a consumer package renders it through its own documented surface.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero. No text from this package enters any model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent: domain reads and writes never touch request prefixes, so nothing here can invalidate provider cache reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Single-process change visibility** — `domain/changed` is an in-process event; a second host process or a reconnecting GUI observes no changes until the cross-process revision pattern deferred in the Agent Note lands.
|
||||
- **No cross-table transactions, secondary indexes, or multi-segment keys** — each write touches one record; triggers and rework points for these extensions are tabled in the Agent Note's deferred-work list.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-storage-domain",
|
||||
"description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-storage": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Runtime of one open domain: authoritative in-memory state, the single
|
||||
* per-domain write chain, and change-event emission. Reads are synchronous
|
||||
* from memory; every write queues on the chain, awaits backend durability
|
||||
* FIRST, then mutates memory, then emits `domain/changed` — a rejected
|
||||
* backend write leaves memory untouched (no divergence between reads and the
|
||||
* medium), and events carry values that equal the in-memory state at
|
||||
* emission, in write order.
|
||||
* @module @deepseek-ai/dsh-storage-domain/src/domain
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { KvUnit } from '@deepseek-ai/dsh-storage'
|
||||
import { DomainError } from './error.ts'
|
||||
import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts'
|
||||
import type { DomainChanged } from './events.ts'
|
||||
|
||||
/** Handle on a domain's global singleton. */
|
||||
export interface DomainGlobal<G> {
|
||||
/**
|
||||
* Current value, synchronously from the authoritative in-memory state.
|
||||
* Before the first `set` this is the spec's `initial`.
|
||||
* @returns the current global value.
|
||||
*/
|
||||
get(): G
|
||||
|
||||
/**
|
||||
* Replace the value durably. Queued on the domain's write chain; the first
|
||||
* `set` is what materializes the global on the medium.
|
||||
* @param value - New value; must satisfy the spec's schema (not re-checked
|
||||
* here — validation happens at the durable read boundary).
|
||||
* @returns resolution after durability and event emission.
|
||||
*/
|
||||
set(value: G): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle on one declared table. Records are plain immutable data: returned
|
||||
* values are the stored objects themselves (no defensive copies) and must not
|
||||
* be mutated in place — replace via `put`/`update`.
|
||||
*/
|
||||
export interface KvTable<K extends string, V> {
|
||||
/**
|
||||
* Read one record, synchronously from memory.
|
||||
* @param key - Record key.
|
||||
* @returns the record, or `undefined` when absent.
|
||||
*/
|
||||
get(key: K): V | undefined
|
||||
|
||||
/**
|
||||
* Snapshot iterator over `[key, record]` pairs. A snapshot, not a live
|
||||
* view: iteration stays stable while queued writes land.
|
||||
* @returns the pair iterator.
|
||||
*/
|
||||
entries(): IterableIterator<[K, V]>
|
||||
|
||||
/**
|
||||
* Snapshot iterator over keys.
|
||||
* @returns the key iterator.
|
||||
*/
|
||||
keys(): IterableIterator<K>
|
||||
|
||||
/** Current record count. */
|
||||
readonly size: number
|
||||
|
||||
/**
|
||||
* Insert or overwrite one record durably.
|
||||
* @param key - Record key.
|
||||
* @param value - The full new record (no partial merge).
|
||||
* @returns resolution after durability and event emission.
|
||||
*/
|
||||
put(key: K, value: V): Promise<void>
|
||||
|
||||
/**
|
||||
* Delete one record durably.
|
||||
* @param key - Record key.
|
||||
* @returns `true` when the record existed, `false` when it was already
|
||||
* absent (no write and no event in that case).
|
||||
*/
|
||||
delete(key: K): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write on the domain's write chain: `fn` sees the
|
||||
* value current at its queue slot, so concurrent updates never interleave.
|
||||
* @param key - Record key; a missing key rejects with `missing-key`.
|
||||
* @param fn - Synchronous pure transform from current to next record.
|
||||
* @returns the stored next record.
|
||||
*/
|
||||
update(key: K, fn: (current: V) => V): Promise<V>
|
||||
}
|
||||
|
||||
/** Global handle of a spec: typed when declared, `never` (inaccessible) when not. */
|
||||
export type DomainGlobalHandleOf<S extends DomainSpec> =
|
||||
S extends { readonly global: DomainGlobalSpec<infer G> } ? DomainGlobal<G> : never
|
||||
|
||||
/** One open domain, typed by its spec. */
|
||||
export interface Domain<S extends DomainSpec> {
|
||||
/** Domain name from the spec. */
|
||||
readonly name: string
|
||||
/** Global singleton handle; a spec without `global` has no usable handle (`never`). */
|
||||
readonly global: DomainGlobalHandleOf<S>
|
||||
/**
|
||||
* Resolve one declared table handle. Handles are stable — repeated calls
|
||||
* return the same instance.
|
||||
* @param name - Declared table name.
|
||||
* @returns the typed table handle.
|
||||
*/
|
||||
table<N extends keyof S['tables'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>
|
||||
|
||||
/**
|
||||
* Close this domain: reject new writes immediately, drain already-queued
|
||||
* writes (their events still emit), release the backend unit, then free
|
||||
* the domain name for a later open. Idempotent — repeated calls share one
|
||||
* teardown. The consumer owns this call (typically as its own `ctx.effect`
|
||||
* disposer); the facility closes any domain left open when it unmounts.
|
||||
* @returns resolution after the unit is released.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/** Internal seam handing table handles their domain-owned write machinery. */
|
||||
interface TableHost {
|
||||
readonly domainName: string
|
||||
readonly unit: KvUnit
|
||||
/** Queue one job on the domain's single write chain. */
|
||||
enqueue<T>(job: () => Promise<T>): Promise<T>
|
||||
/** Throw `closed` once the domain has fully closed (reads stay valid while draining). */
|
||||
assertReadable(): void
|
||||
/** Emit `domain/changed` for one durably landed write. */
|
||||
emitChanged(change: DomainChanged): void
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* The single domain implementation behind the {@link Domain} interface. The
|
||||
* facility constructs it from a validated `loadAll` snapshot and erases it to
|
||||
* `Domain<S>`; nothing outside this package constructs one.
|
||||
*/
|
||||
export class DomainImpl {
|
||||
/** Domain name from the spec. */
|
||||
readonly name: string
|
||||
|
||||
private readonly tables = new Map<string, KvTableImpl<string, unknown>>()
|
||||
private globalValue: unknown
|
||||
private readonly globalHandle?: DomainGlobal<unknown>
|
||||
|
||||
/** Tail of the write chain; every link settles (rejections are observed by the caller's slice). */
|
||||
private chain: Promise<void> = Promise.resolve()
|
||||
/** Set when close begins: new writes reject while already-queued writes drain. */
|
||||
private disposing = false
|
||||
/** Set when close finishes (chain drained, unit closed): reads reject from here on. */
|
||||
private closed = false
|
||||
private disposal?: Promise<void>
|
||||
|
||||
/**
|
||||
* @param ctx - Context that carries `domain/changed` emissions.
|
||||
* @param spec - The domain declaration.
|
||||
* @param unit - The opened backend unit; this instance owns its lifecycle.
|
||||
* @param records - Validated records from the unit's `loadAll`, one entry
|
||||
* per declared table (empty maps included) — the facility builds it from
|
||||
* the spec, so the entry set IS the table set.
|
||||
* @param globalValue - Validated stored global, or the spec's `initial`
|
||||
* when the medium held none; `undefined` when the spec declares no global.
|
||||
* @param onClosed - Facility hook run once after teardown completes; frees
|
||||
* the domain name for a later open.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
spec: DomainSpec,
|
||||
private readonly unit: KvUnit,
|
||||
records: Map<string, Map<string, unknown>>,
|
||||
globalValue: unknown,
|
||||
private readonly onClosed: () => void,
|
||||
) {
|
||||
this.name = spec.name
|
||||
const host: TableHost = {
|
||||
domainName: spec.name,
|
||||
unit,
|
||||
enqueue: job => this.enqueue(job),
|
||||
assertReadable: () => { this.assertReadable() },
|
||||
emitChanged: (change) => { this.emitChanged(change) },
|
||||
}
|
||||
for (const [table, tableRecords] of records) {
|
||||
this.tables.set(table, new KvTableImpl(host, table, tableRecords))
|
||||
}
|
||||
if (spec.global !== undefined) {
|
||||
this.globalValue = globalValue
|
||||
this.globalHandle = {
|
||||
get: () => {
|
||||
this.assertReadable()
|
||||
return this.globalValue
|
||||
},
|
||||
set: value => this.enqueue(async () => {
|
||||
await this.unit.setGlobal(value)
|
||||
this.globalValue = value
|
||||
this.emitChanged({ domain: this.name, table: '', key: '', operation: 'put', value })
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Global singleton handle; accessing it on a spec that declares no global is a caller bug and throws. */
|
||||
get global(): DomainGlobal<unknown> {
|
||||
if (this.globalHandle === undefined) {
|
||||
throw new Error(`domain '${this.name}' declares no global`)
|
||||
}
|
||||
return this.globalHandle
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one declared table handle; an undeclared name is a caller bug
|
||||
* and throws.
|
||||
* @param name - Declared table name.
|
||||
* @returns the stable table handle.
|
||||
*/
|
||||
table(name: string): KvTable<string, unknown> {
|
||||
const table = this.tables.get(name)
|
||||
if (table === undefined) {
|
||||
throw new Error(`domain '${this.name}' declares no table '${name}'`)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this domain: reject new writes immediately, drain already-queued
|
||||
* writes (their events still emit), close the unit, then free the name via
|
||||
* the facility hook. Idempotent — repeated calls share one teardown.
|
||||
* @returns resolution after the unit is released.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
this.disposal ??= this.runClose()
|
||||
return this.disposal
|
||||
}
|
||||
|
||||
private async runClose(): Promise<void> {
|
||||
this.disposing = true
|
||||
// Chain links never reject (each is settled via then(noop, noop)), so
|
||||
// this await is a pure drain barrier.
|
||||
await this.chain
|
||||
await this.unit.close()
|
||||
this.closed = true
|
||||
this.onClosed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one post-durability change notification, containing observer
|
||||
* failures: the write is already committed (medium and memory both hold
|
||||
* the new state), so a throwing listener must not retroactively reject it.
|
||||
*/
|
||||
private emitChanged(change: DomainChanged): void {
|
||||
try {
|
||||
this.ctx.emit('domain/changed', change)
|
||||
} catch (error) {
|
||||
// Swallows synchronous observer exceptions only: emit dispatches
|
||||
// listeners inline and nothing else runs in the try. The event is a
|
||||
// notification, not a transaction participant — the commit point has
|
||||
// passed, so containment (with a log) is the only correct outcome.
|
||||
this.ctx.logger.warn(`domain '${this.name}': domain/changed listener failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue<T>(job: () => Promise<T>): Promise<T> {
|
||||
if (this.disposing) {
|
||||
return Promise.reject(new DomainError('closed', `domain '${this.name}' is closed`))
|
||||
}
|
||||
const result = this.chain.then(job)
|
||||
this.chain = result.then(noop, noop)
|
||||
return result
|
||||
}
|
||||
|
||||
private assertReadable(): void {
|
||||
if (this.closed) {
|
||||
throw new DomainError('closed', `domain '${this.name}' is closed`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Table handle bound to one in-memory record map and its domain's write chain. */
|
||||
class KvTableImpl<K extends string, V> implements KvTable<K, V> {
|
||||
constructor(
|
||||
private readonly host: TableHost,
|
||||
private readonly tableName: string,
|
||||
private readonly records: Map<string, unknown>,
|
||||
) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
this.host.assertReadable()
|
||||
return this.records.get(key) as V | undefined
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[K, V]> {
|
||||
this.host.assertReadable()
|
||||
return ([...this.records.entries()] as [K, V][])[Symbol.iterator]()
|
||||
}
|
||||
|
||||
keys(): IterableIterator<K> {
|
||||
this.host.assertReadable()
|
||||
return ([...this.records.keys()] as K[])[Symbol.iterator]()
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
this.host.assertReadable()
|
||||
return this.records.size
|
||||
}
|
||||
|
||||
put(key: K, value: V): Promise<void> {
|
||||
return this.host.enqueue(async () => {
|
||||
await this.host.unit.putRecord(this.tableName, key, value)
|
||||
this.records.set(key, value)
|
||||
this.emitPut(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
delete(key: K): Promise<boolean> {
|
||||
return this.host.enqueue(async () => {
|
||||
// Existence is decided at this job's chain slot, not at call time: an
|
||||
// earlier queued put of the same key makes this delete observe it.
|
||||
if (!this.records.has(key)) return false
|
||||
await this.host.unit.deleteRecord(this.tableName, key)
|
||||
this.records.delete(key)
|
||||
this.host.emitChanged({
|
||||
domain: this.host.domainName,
|
||||
table: this.tableName,
|
||||
key,
|
||||
operation: 'deleted',
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
update(key: K, fn: (current: V) => V): Promise<V> {
|
||||
return this.host.enqueue(async () => {
|
||||
if (!this.records.has(key)) {
|
||||
throw new DomainError(
|
||||
'missing-key',
|
||||
`domain '${this.host.domainName}' table '${this.tableName}' has no record '${key}' to update`,
|
||||
)
|
||||
}
|
||||
const next = fn(this.records.get(key) as V)
|
||||
await this.host.unit.putRecord(this.tableName, key, next)
|
||||
this.records.set(key, next)
|
||||
this.emitPut(key, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
private emitPut(key: K, value: V): void {
|
||||
this.host.emitChanged({
|
||||
domain: this.host.domainName,
|
||||
table: this.tableName,
|
||||
key,
|
||||
operation: 'put',
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Error vocabulary of the domain data form.
|
||||
* @module @deepseek-ai/dsh-storage-domain/src/error
|
||||
*/
|
||||
|
||||
/** Discriminant codes carried by every {@link DomainError}. */
|
||||
export type DomainErrorCode =
|
||||
| 'already-open'
|
||||
| 'facet-unsupported'
|
||||
| 'invalid-record'
|
||||
| 'missing-key'
|
||||
| 'closed'
|
||||
|
||||
/** Location of the record that failed schema validation at the durable boundary. */
|
||||
export interface InvalidRecordDetail {
|
||||
/** Table holding the rejected record; `''` for the global singleton. */
|
||||
readonly table: string
|
||||
/** Key of the rejected record; `''` for the global singleton. */
|
||||
readonly key: string
|
||||
}
|
||||
|
||||
/** Construction options: standard `cause` plus the `invalid-record` location. */
|
||||
export interface DomainErrorOptions extends ErrorOptions {
|
||||
/** Present exactly when `code` is `invalid-record`. */
|
||||
readonly detail?: InvalidRecordDetail
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown by the domain layer. The `code` is the stable contract
|
||||
* consumers may switch on; `message` is diagnostic prose. Backend failures
|
||||
* (`backend-not-found`, `version-mismatch`, …) pass through as
|
||||
* `StorageError` — the domain layer does not rewrap them.
|
||||
*/
|
||||
export class DomainError extends Error {
|
||||
override readonly name = 'DomainError'
|
||||
|
||||
/** Present exactly when `code` is `invalid-record`. */
|
||||
readonly detail?: InvalidRecordDetail
|
||||
|
||||
/**
|
||||
* @param code - Stable discriminant for the failure class.
|
||||
* @param message - Human-readable diagnostic detail.
|
||||
* @param options - Standard error options plus the `invalid-record` location.
|
||||
*/
|
||||
constructor(
|
||||
readonly code: DomainErrorCode,
|
||||
message: string,
|
||||
options?: DomainErrorOptions,
|
||||
) {
|
||||
super(message, options)
|
||||
if (options?.detail) this.detail = options.detail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Change-event vocabulary of the domain data form. Every durable write emits
|
||||
* one event after the backend resolves durability, carrying the new snapshot
|
||||
* and an operation discriminant — never the old value (a diffing consumer
|
||||
* keeps its own previous snapshot). This is the event source for cross-process
|
||||
* change push (RPC frames) in a later phase.
|
||||
* @module @deepseek-ai/dsh-storage-domain/src/events
|
||||
*/
|
||||
|
||||
/** Shared location fields of one durable domain change. */
|
||||
export interface DomainChangedBase {
|
||||
/** Owning domain name. */
|
||||
readonly domain: string
|
||||
/** Table name; `''` for a global-singleton write. */
|
||||
readonly table: string
|
||||
/** Record key; `''` for a global-singleton write. */
|
||||
readonly key: string
|
||||
}
|
||||
|
||||
/** A record (or the global singleton) was inserted or overwritten. */
|
||||
export interface DomainChangedPut extends DomainChangedBase {
|
||||
readonly operation: 'put'
|
||||
/** The new snapshot. */
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
/** A record was deleted; tombstones carry no value. */
|
||||
export interface DomainChangedDeleted extends DomainChangedBase {
|
||||
readonly operation: 'deleted'
|
||||
readonly value?: never
|
||||
}
|
||||
|
||||
/** One durable domain change; a closed union — switch on `operation`. */
|
||||
export type DomainChanged = DomainChangedPut | DomainChangedDeleted
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Domain data form (`ctx.storage.domain`): schema-validated, change-emitting
|
||||
* KV domains over storage backends. The single implementation of the domain
|
||||
* layer — consumers depend on this package and never touch backends directly.
|
||||
* Plugin `Config` is schemastery; record schemas inside domain specs are zod
|
||||
* (see `src/spec.ts` for the split rationale).
|
||||
* @module @deepseek-ai/dsh-storage-domain
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { DomainError } from './error.ts'
|
||||
import { descriptorOf } from './spec.ts'
|
||||
import type { DomainSpec } from './spec.ts'
|
||||
import { DomainImpl } from './domain.ts'
|
||||
import type { Domain } from './domain.ts'
|
||||
|
||||
export { DomainError } from './error.ts'
|
||||
export type { DomainErrorCode, DomainErrorOptions, InvalidRecordDetail } from './error.ts'
|
||||
export { defineDomain, domainTable, descriptorOf } from './spec.ts'
|
||||
export type {
|
||||
DomainSpec, DomainGlobalSpec, DomainTableSpec,
|
||||
TableKeyOf, TableValueOf, GlobalValueOf,
|
||||
} from './spec.ts'
|
||||
export type { DomainChanged } from './events.ts'
|
||||
export type { Domain, DomainGlobal, DomainGlobalHandleOf, KvTable } from './domain.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-storage' {
|
||||
interface StorageForms {
|
||||
domain: DomainFacility
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'storage-domain'
|
||||
/** The storage hub must be present before the form can mount. */
|
||||
export const inject = ['storage']
|
||||
|
||||
/**
|
||||
* 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<string, string>
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
backend: z.string().required(),
|
||||
routes: z.dict(z.string()).default({}),
|
||||
})
|
||||
|
||||
/**
|
||||
* The mounted domain facility. Opens declared domains over routed backends;
|
||||
* one facility instance owns the open-domain table and enforces single-open
|
||||
* per domain name.
|
||||
*/
|
||||
export class DomainFacility {
|
||||
private readonly domains = new Map<string, DomainImpl>()
|
||||
/** Names reserved by an in-flight or completed open, so concurrent opens of one name fail loud. */
|
||||
private readonly reserved = new Set<string>()
|
||||
|
||||
/**
|
||||
* @param ctx - Context of the domain plugin; open-domain effects and change
|
||||
* events attach here.
|
||||
* @param config - Validated plugin config.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: Config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Open one declared domain. Steps, each failing the whole call: reject a
|
||||
* name that is already open (`already-open`); resolve the backend route
|
||||
* (`backend-not-found` passes through from the hub); require its `kv` facet
|
||||
* (`facet-unsupported`); open the unit projected from the spec (backend
|
||||
* `version-mismatch`/`malformed-medium` pass through); load and validate
|
||||
* every stored record against the spec's zod schemas (`invalid-record`
|
||||
* with the offending table and key); construct the domain.
|
||||
*
|
||||
* Lifecycle: the CALLER owns the returned handle and closes it via
|
||||
* `Domain.close()` (typically as its own `ctx.effect` disposer) — the
|
||||
* facility does not tie the domain to any consumer fiber. Domains still
|
||||
* open when the facility unmounts are closed by the plugin disposer.
|
||||
* @param spec - The domain declaration, typically from `defineDomain`.
|
||||
* @returns the opened domain handle, typed by the spec.
|
||||
*/
|
||||
async open<S extends DomainSpec>(spec: S): Promise<Domain<S>> {
|
||||
if (this.reserved.has(spec.name)) {
|
||||
throw new DomainError('already-open', `domain '${spec.name}' is already open`)
|
||||
}
|
||||
this.reserved.add(spec.name)
|
||||
try {
|
||||
const backendName = this.config.routes?.[spec.name] ?? this.config.backend
|
||||
const backend = this.ctx.storage.backend.get(backendName)
|
||||
if (!backend.kv) {
|
||||
throw new DomainError(
|
||||
'facet-unsupported',
|
||||
`backend '${backendName}' routed for domain '${spec.name}' has no kv facet`,
|
||||
)
|
||||
}
|
||||
const unit = await backend.kv.open(descriptorOf(spec))
|
||||
try {
|
||||
const snapshot = await unit.loadAll()
|
||||
const tables = new Map<string, Map<string, unknown>>()
|
||||
for (const [table, tableSpec] of Object.entries(spec.tables)) {
|
||||
const records = new Map<string, unknown>()
|
||||
for (const [key, raw] of Object.entries(snapshot.tables[table] ?? {})) {
|
||||
records.set(key, parseRecord(spec.name, table, key, () => tableSpec.valueSchema.parse(raw)))
|
||||
}
|
||||
tables.set(table, records)
|
||||
}
|
||||
// A null stored global means "never written": serve `initial` without
|
||||
// materializing it — the first `set` writes.
|
||||
const globalSpec = spec.global
|
||||
const globalValue = globalSpec === undefined
|
||||
? undefined
|
||||
: snapshot.global === null
|
||||
? globalSpec.initial
|
||||
: parseRecord(spec.name, '', '', () => globalSpec.schema.parse(snapshot.global))
|
||||
// The onClosed hook runs strictly after teardown completes: writes
|
||||
// landing during the drain still emit domain/changed, and the domain
|
||||
// stays resolvable (the package invariant cross-checks each event)
|
||||
// until fully closed — only then does the name free up for reopening.
|
||||
const domain: DomainImpl = new DomainImpl(this.ctx, spec, unit, tables, globalValue, () => {
|
||||
this.domains.delete(spec.name)
|
||||
this.reserved.delete(spec.name)
|
||||
})
|
||||
this.domains.set(spec.name, domain)
|
||||
// The single type-erasure point: DomainImpl is the untyped runtime,
|
||||
// Domain<S> the spec-typed view; the unknown hop is required because
|
||||
// S's conditional global-handle type stays unresolved here.
|
||||
return domain as unknown as Domain<S>
|
||||
} catch (error) {
|
||||
await unit.close()
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
// Any failure means the domain never registered (nothing can throw
|
||||
// after it), so releasing the name reservation is unconditional.
|
||||
this.reserved.delete(spec.name)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an open domain by name, untyped. Diagnostic surface (the package
|
||||
* invariant cross-checks change events against live domain state); typed
|
||||
* consumers hold the handle returned by {@link open}.
|
||||
* @param name - Domain name.
|
||||
* @returns the open domain runtime, or `undefined` when not open.
|
||||
*/
|
||||
get(name: string): DomainImpl | undefined {
|
||||
return this.domains.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every domain still open on this facility. The unmount path for
|
||||
* consumers that never called `Domain.close()` themselves; closing is
|
||||
* idempotent, so double-closing an already-closed domain is harmless.
|
||||
* @returns resolution after every unit is released.
|
||||
*/
|
||||
async closeAll(): Promise<void> {
|
||||
await Promise.all([...this.domains.values()].map(domain => domain.close()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one zod parse, translating failure to `invalid-record` with its location. */
|
||||
function parseRecord<T>(domain: string, table: string, key: string, parse: () => T): T {
|
||||
try {
|
||||
return parse()
|
||||
} catch (error) {
|
||||
const slot = table === '' ? 'global' : `record '${key}' in table '${table}'`
|
||||
throw new DomainError(
|
||||
'invalid-record',
|
||||
`domain '${domain}': stored ${slot} does not match its schema`,
|
||||
{ detail: { table, key }, cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the domain data form on the storage hub.
|
||||
* @param ctx - Plugin context.
|
||||
* @param config - Validated plugin config.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
const facility = new DomainFacility(ctx, config)
|
||||
ctx.effect(() => {
|
||||
const unmount = ctx.storage.mount('domain', facility)
|
||||
return async () => {
|
||||
// Close leftovers before unmounting: draining writes still emit
|
||||
// domain/changed, whose invariant resolves the facility through the hub.
|
||||
await facility.closeAll()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-domain`: every
|
||||
* `domain/changed` event must agree with the emitting domain's authoritative
|
||||
* in-memory state (the owned event-stream ↔ mutable-data relationship of this
|
||||
* package). Writes emit strictly after mutating memory and the write chain
|
||||
* serializes them, so at emission time the event's snapshot equals the
|
||||
* current read — any divergence means a write path skipped the chain or
|
||||
* emitted a stale value.
|
||||
* @module @deepseek-ai/dsh-storage-domain/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { DomainChanged } from './events.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-storage-domain'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'storage-domain-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Install the change-event ↔ memory-state agreement check. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
ctx.on('domain/changed', (change: DomainChanged) => {
|
||||
const domain = ctx.storage.form('domain').get(change.domain)
|
||||
if (domain === undefined) {
|
||||
return fail(`domain/changed for '${change.domain}' emitted while that domain is not open`)
|
||||
}
|
||||
if (change.table === '') {
|
||||
// Global write: the event snapshot must be the current global value.
|
||||
if (domain.global.get() !== change.value) {
|
||||
return fail(`domain/changed global value for '${change.domain}' differs from the in-memory global`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const current = domain.table(change.table).get(change.key)
|
||||
switch (change.operation) {
|
||||
case 'deleted':
|
||||
if (current !== undefined) {
|
||||
return fail(
|
||||
`domain/changed deletion of '${change.domain}'.'${change.table}'['${change.key}'] `
|
||||
+ 'emitted while the record is still in memory',
|
||||
)
|
||||
}
|
||||
return
|
||||
case 'put':
|
||||
if (current !== change.value) {
|
||||
return fail(
|
||||
`domain/changed value for '${change.domain}'.'${change.table}'['${change.key}'] `
|
||||
+ 'differs from the in-memory record',
|
||||
)
|
||||
}
|
||||
return
|
||||
default:
|
||||
change satisfies never
|
||||
}
|
||||
}, { global: true })
|
||||
}, { inject: ['storage'] })
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Domain declaration vocabulary. A spec object is the single source of a
|
||||
* domain's identity, layout, and record schemas: the owning package defines
|
||||
* it once with {@link defineDomain} and both the type surface and the runtime
|
||||
* (validation, descriptor projection) derive from it. Record schemas are zod
|
||||
* (`z.infer` keeps types un-duplicated and the same schemas later project to
|
||||
* RPC wire schemas); plugin `Config` stays schemastery.
|
||||
* @module @deepseek-ai/dsh-storage-domain/src/spec
|
||||
*/
|
||||
|
||||
import type { ZodType } from 'zod'
|
||||
import { UNIT_NAME_RE, type KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
|
||||
|
||||
/** Global singleton declaration: schema plus the value used before the first write. */
|
||||
export interface DomainGlobalSpec<G> {
|
||||
/** Validates the stored global at the durable boundary. */
|
||||
readonly schema: ZodType<G>
|
||||
/** Value served when the medium holds no global yet; not written until the first `set`. */
|
||||
readonly initial: G
|
||||
}
|
||||
|
||||
/**
|
||||
* One table declaration. `K` is a phantom key type (typically a branded
|
||||
* string) carried for compile-time projection only; keys are plain strings on
|
||||
* the medium.
|
||||
*/
|
||||
export interface DomainTableSpec<K extends string = string, V = unknown> {
|
||||
/** Validates every stored record at the durable boundary. */
|
||||
readonly valueSchema: ZodType<V>
|
||||
/** Phantom carrier for the key type; never present at runtime. */
|
||||
readonly __key?: K
|
||||
}
|
||||
|
||||
/** Static declaration of one domain: identity, version, and record layout. */
|
||||
export interface DomainSpec {
|
||||
/** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */
|
||||
readonly name: string
|
||||
/** Domain format version; a medium stamped with a different version rejects at open. */
|
||||
readonly version: number
|
||||
/** Optional global singleton slot. */
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
/** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
|
||||
readonly tables: Record<string, DomainTableSpec>
|
||||
}
|
||||
|
||||
/** Key type of one declared table, recovered from its phantom carrier. */
|
||||
export type TableKeyOf<S extends DomainSpec, N extends keyof S['tables']> =
|
||||
S['tables'][N] extends DomainTableSpec<infer K> ? K : never
|
||||
|
||||
/** Value type of one declared table. */
|
||||
export type TableValueOf<S extends DomainSpec, N extends keyof S['tables']> =
|
||||
S['tables'][N] extends DomainTableSpec<string, infer V> ? V : never
|
||||
|
||||
/** Global value type of a spec; `never` when the spec declares no global. */
|
||||
export type GlobalValueOf<S extends DomainSpec> =
|
||||
S['global'] extends DomainGlobalSpec<infer G> ? G : never
|
||||
|
||||
/**
|
||||
* Declare one table.
|
||||
* @param schema - zod schema validating every stored record of this table.
|
||||
* @returns the table declaration, key-typed by `K`.
|
||||
*/
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V> {
|
||||
return { valueSchema: schema }
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity helper that pins a spec's literal types and validates its shape.
|
||||
* Misconfiguration fails loud at the owning package's module load, before any
|
||||
* medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version
|
||||
* that is not a non-negative integer, or a global schema that accepts `null`
|
||||
* all throw. The `null` rejection guards round-tripping: backends store the
|
||||
* global as opaque JSON with `null` as the "never written" sentinel, so a
|
||||
* nullable global would be indistinguishable from an absent one on reopen
|
||||
* (a stored `null` silently reverts to `initial`).
|
||||
* @param spec - The domain declaration.
|
||||
* @returns the same spec, narrowed to its literal type.
|
||||
*/
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S {
|
||||
if (!UNIT_NAME_RE.test(spec.name)) {
|
||||
throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`)
|
||||
}
|
||||
if (!Number.isInteger(spec.version) || spec.version < 0) {
|
||||
throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`)
|
||||
}
|
||||
for (const table of Object.keys(spec.tables)) {
|
||||
if (!UNIT_NAME_RE.test(table)) {
|
||||
throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)
|
||||
}
|
||||
}
|
||||
if (spec.global !== undefined && spec.global.schema.safeParse(null).success) {
|
||||
throw new Error(
|
||||
`domain '${spec.name}' global schema must not accept null: `
|
||||
+ 'null is the medium\'s "never written" sentinel, so a stored null could not round-trip',
|
||||
)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a spec onto the backend-facing unit descriptor.
|
||||
* @param spec - The domain declaration.
|
||||
* @returns the descriptor handed to `KvFacet.open`.
|
||||
*/
|
||||
export function descriptorOf(spec: DomainSpec): KvUnitDescriptor {
|
||||
return {
|
||||
name: spec.name,
|
||||
version: spec.version,
|
||||
tables: Object.keys(spec.tables),
|
||||
hasGlobal: spec.global !== undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility, defineDomain, domainTable } from '../src/index.ts'
|
||||
import type { Config } from '../src/index.ts'
|
||||
import type { DomainChanged } from '../src/events.ts'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from './helpers/memory-backend.ts'
|
||||
|
||||
const itemSchema = z.object({ label: z.string(), count: z.number().int() })
|
||||
type Item = z.infer<typeof itemSchema>
|
||||
|
||||
const settingsSchema = z.object({ theme: z.string() })
|
||||
|
||||
const spec = defineDomain({
|
||||
name: 'demo',
|
||||
version: 1,
|
||||
global: { schema: settingsSchema, initial: { theme: 'plain' } },
|
||||
tables: { items: domainTable<string, Item>(itemSchema) },
|
||||
})
|
||||
|
||||
const bareSpec = defineDomain({
|
||||
name: 'bare',
|
||||
version: 1,
|
||||
tables: { rows: domainTable<string, Item>(itemSchema) },
|
||||
})
|
||||
|
||||
/** Boot a context with the storage hub, one memory backend, and a facility over it. */
|
||||
async function harness(options?: { pool?: MemoryMediaPool; config?: Partial<Config> }) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
const backend = new MemoryStorageBackend(options?.pool)
|
||||
ctx.storage.backend.register('memory', backend)
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {}, ...options?.config })
|
||||
// Mounted, not just constructed: the package invariant resolves the form
|
||||
// through ctx.storage to cross-check every domain/changed emission.
|
||||
ctx.storage.mount('domain', facility)
|
||||
const changes: DomainChanged[] = []
|
||||
ctx.on('domain/changed', (change) => { changes.push(change) })
|
||||
return { ctx, backend, facility, changes }
|
||||
}
|
||||
|
||||
describe('defineDomain', () => {
|
||||
it('rejects invalid names and versions loudly', () => {
|
||||
expect(() => defineDomain({ name: 'Bad-Name', version: 1, tables: {} })).toThrow(/must match/)
|
||||
expect(() => defineDomain({ name: 'ok', version: 1.5, tables: {} })).toThrow(/non-negative integer/)
|
||||
expect(() => defineDomain({
|
||||
name: 'ok', version: 1, tables: { 'Bad Table': domainTable<string, Item>(itemSchema) },
|
||||
})).toThrow(/table name/)
|
||||
})
|
||||
|
||||
it('rejects a global schema that accepts null (the never-written sentinel)', () => {
|
||||
expect(() => defineDomain({
|
||||
name: 'ok',
|
||||
version: 1,
|
||||
global: { schema: settingsSchema.nullable(), initial: null },
|
||||
tables: {},
|
||||
})).toThrow(/must not accept null/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DomainFacility.open', () => {
|
||||
it('opens, reads back stored records, and rejects a second open of the same name', async () => {
|
||||
const { facility } = await harness()
|
||||
const domain = await facility.open(spec)
|
||||
await domain.table('items').put('a', { label: 'first', count: 1 })
|
||||
await expect(facility.open(spec)).rejects.toMatchObject({ name: 'DomainError', code: 'already-open' })
|
||||
expect(domain.table('items').get('a')).toEqual({ label: 'first', count: 1 })
|
||||
})
|
||||
|
||||
it('routes per domain name and fails loud on an unregistered route target', async () => {
|
||||
const { facility } = await harness({ config: { routes: { demo: 'nonexistent' } } })
|
||||
await expect(facility.open(spec)).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'backend-not-found',
|
||||
})
|
||||
// The failed open releases the name for a later attempt.
|
||||
const { facility: healthy } = await harness()
|
||||
await expect(healthy.open(spec)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a backend without the kv facet', async () => {
|
||||
const { ctx, facility } = await harness({ config: { backend: 'nokv' } })
|
||||
ctx.storage.backend.register('nokv', { close: async () => {} })
|
||||
await expect(facility.open(spec)).rejects.toMatchObject({ code: 'facet-unsupported' })
|
||||
})
|
||||
|
||||
it('falls back to the default backend when no route table is configured', async () => {
|
||||
// A second, unmounted facility whose config omits `routes` entirely
|
||||
// (exactOptionalPropertyTypes forbids an explicit undefined). Opening
|
||||
// emits no events, so the mounted facility's invariant never consults it.
|
||||
const { ctx } = await harness()
|
||||
const routeless = new DomainFacility(ctx, { backend: 'memory' })
|
||||
await expect(routeless.open(bareSpec)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('treats a table key the backend omitted from loadAll as empty', async () => {
|
||||
// A sparse backend: loadAll omits declared table keys entirely instead of
|
||||
// returning them as empty objects.
|
||||
const { ctx, facility } = await harness({ config: { backend: 'sparse' } })
|
||||
ctx.storage.backend.register('sparse', {
|
||||
kv: {
|
||||
open: async () => ({
|
||||
loadAll: async () => ({ tables: {}, global: null }),
|
||||
putRecord: async () => {},
|
||||
deleteRecord: async () => {},
|
||||
setGlobal: async () => {},
|
||||
close: async () => {},
|
||||
}),
|
||||
},
|
||||
close: async () => {},
|
||||
})
|
||||
const domain = await facility.open(bareSpec)
|
||||
expect(domain.table('rows').size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects stored records that fail their schema, naming table and key', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
{
|
||||
const { facility } = await harness({ pool })
|
||||
await (await facility.open(spec)).table('items').put('bad', { label: 'x', count: 2 })
|
||||
}
|
||||
pool.media.get('demo')!.tables.get('items')!.set('bad', { label: 'x', count: 'NaN' })
|
||||
const { facility } = await harness({ pool })
|
||||
await expect(facility.open(spec)).rejects.toMatchObject({
|
||||
code: 'invalid-record',
|
||||
detail: { table: 'items', key: 'bad' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a stored global that fails its schema with the global marker', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
pool.versions.set('demo', 1)
|
||||
pool.media.set('demo', { tables: new Map(), global: { theme: 42 } })
|
||||
const { facility } = await harness({ pool })
|
||||
await expect(facility.open(spec)).rejects.toMatchObject({
|
||||
code: 'invalid-record',
|
||||
detail: { table: '', key: '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through a backend version mismatch', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
pool.versions.set('demo', 7)
|
||||
const { facility } = await harness({ pool })
|
||||
await expect(facility.open(spec)).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'version-mismatch',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin apply', () => {
|
||||
it('mounts the facility as ctx.storage.domain through the plugin effect', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
const DomainPlugin = await import('../src/index.ts')
|
||||
const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' })
|
||||
expect(ctx.storage.domain).toBeInstanceOf(DomainFacility)
|
||||
await fiber.dispose()
|
||||
expect(() => ctx.storage.form('domain')).toThrow(/not mounted/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('table and snapshot reads', () => {
|
||||
it('serves entries, keys, and size as stable snapshots; unknown table names throw', async () => {
|
||||
const { facility } = await harness()
|
||||
const domain = await facility.open(spec)
|
||||
const table = domain.table('items')
|
||||
await table.put('a', { label: 'x', count: 1 })
|
||||
await table.put('b', { label: 'y', count: 2 })
|
||||
expect(table.size).toBe(2)
|
||||
expect([...table.keys()].sort()).toEqual(['a', 'b'])
|
||||
expect(new Map(table.entries()).get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(() => domain.table('nope' as never)).toThrow(/declares no table/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('KvTable writes', () => {
|
||||
it('serializes concurrent updates on one key without losing increments', async () => {
|
||||
const { facility } = await harness()
|
||||
const table = (await facility.open(spec)).table('items')
|
||||
await table.put('counter', { label: 'c', count: 0 })
|
||||
await Promise.all(Array.from({ length: 50 }, () =>
|
||||
table.update('counter', current => ({ ...current, count: current.count + 1 }))))
|
||||
expect(table.get('counter')).toEqual({ label: 'c', count: 50 })
|
||||
})
|
||||
|
||||
it('update rejects a missing key; delete reports prior existence', async () => {
|
||||
const { facility } = await harness()
|
||||
const table = (await facility.open(spec)).table('items')
|
||||
await expect(table.update('ghost', v => v)).rejects.toMatchObject({ code: 'missing-key' })
|
||||
await table.put('a', { label: 'x', count: 1 })
|
||||
await expect(table.delete('a')).resolves.toBe(true)
|
||||
await expect(table.delete('a')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => {
|
||||
const { facility, changes } = await harness()
|
||||
const domain = await facility.open(spec)
|
||||
const table = domain.table('items')
|
||||
await table.put('a', { label: 'x', count: 1 })
|
||||
await table.update('a', current => ({ ...current, count: 2 }))
|
||||
await table.delete('a')
|
||||
await table.delete('a') // no event: already absent
|
||||
await domain.global.set({ theme: 'dark' })
|
||||
expect(changes).toEqual([
|
||||
{ domain: 'demo', table: 'items', key: 'a', operation: 'put', value: { label: 'x', count: 1 } },
|
||||
{ domain: 'demo', table: 'items', key: 'a', operation: 'put', value: { label: 'x', count: 2 } },
|
||||
{ domain: 'demo', table: 'items', key: 'a', operation: 'deleted' },
|
||||
{ domain: 'demo', table: '', key: '', operation: 'put', value: { theme: 'dark' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('durability failure', () => {
|
||||
it('leaves memory untouched and emits nothing when the backend rejects a write', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { facility, changes } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
const table = domain.table('items')
|
||||
await table.put('a', { label: 'x', count: 1 })
|
||||
const seen = changes.length
|
||||
|
||||
pool.failNextWrites = 3
|
||||
await expect(table.put('a', { label: 'x', count: 99 })).rejects.toThrow(/injected/)
|
||||
await expect(table.update('a', c => ({ ...c, count: c.count + 1 }))).rejects.toThrow(/injected/)
|
||||
await expect(table.delete('a')).rejects.toThrow(/injected/)
|
||||
|
||||
// Reads still serve the pre-failure record; no events leaked.
|
||||
expect(table.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(pool.media.get('demo')!.tables.get('items')!.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(changes).toHaveLength(seen)
|
||||
|
||||
// The chain survives rejections: the next write lands cleanly with no residue.
|
||||
await table.update('a', c => ({ ...c, count: c.count + 1 }))
|
||||
expect(table.get('a')).toEqual({ label: 'x', count: 2 })
|
||||
})
|
||||
|
||||
it('keeps serving initial when the first global set fails durability', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { facility } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
pool.failNextWrites = 1
|
||||
await expect(domain.global.set({ theme: 'dark' })).rejects.toThrow(/injected/)
|
||||
expect(domain.global.get()).toEqual({ theme: 'plain' })
|
||||
expect(pool.media.get('demo')!.global).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('global singleton', () => {
|
||||
it('serves initial before first set without materializing, then persists the first set', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
{
|
||||
const { facility } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
expect(domain.global.get()).toEqual({ theme: 'plain' })
|
||||
expect(pool.media.get('demo')!.global).toBeNull() // initial never touches the medium
|
||||
await domain.global.set({ theme: 'dark' })
|
||||
expect(pool.media.get('demo')!.global).toEqual({ theme: 'dark' })
|
||||
}
|
||||
const { facility } = await harness({ pool })
|
||||
expect((await facility.open(spec)).global.get()).toEqual({ theme: 'dark' })
|
||||
})
|
||||
|
||||
it('throws on access when the spec declares no global', async () => {
|
||||
const { facility } = await harness()
|
||||
const domain = await facility.open(bareSpec)
|
||||
expect(() => (domain as { global: unknown }).global).toThrow(/declares no global/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('close and lifecycle', () => {
|
||||
it('close drains queued writes, then rejects reads and writes, and frees the name', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { facility } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
const table = domain.table('items')
|
||||
const pending = Promise.all([
|
||||
table.put('a', { label: 'x', count: 1 }),
|
||||
table.put('b', { label: 'y', count: 2 }),
|
||||
])
|
||||
await Promise.all([domain.close(), domain.close()]) // idempotent
|
||||
await pending // queued before close → still landed
|
||||
// Durability is the drain contract: both queued writes reached the medium.
|
||||
expect([...pool.media.get('demo')!.tables.get('items')!.keys()].sort()).toEqual(['a', 'b'])
|
||||
await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'closed' })
|
||||
expect(() => table.get('a')).toThrow(/closed/)
|
||||
// The name is free again: reopening sees the drained state.
|
||||
const reopened = await facility.open(spec)
|
||||
expect([...reopened.table('items').keys()].sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('facility unmount closes domains the consumer never closed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
const DomainPlugin = await import('../src/index.ts')
|
||||
const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' })
|
||||
const domain = await ctx.storage.domain.open(bareSpec)
|
||||
const table = domain.table('rows')
|
||||
await table.put('a', { label: 'x', count: 1 })
|
||||
await fiber.dispose()
|
||||
await expect(table.put('b', { label: 'y', count: 2 })).rejects.toMatchObject({ code: 'closed' })
|
||||
expect(() => ctx.storage.form('domain')).toThrow(/not mounted/)
|
||||
})
|
||||
|
||||
it('contains a throwing domain/changed listener without rejecting the committed write', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { ctx, facility, changes } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
const table = domain.table('items')
|
||||
ctx.on('domain/changed', () => {
|
||||
throw new Error('hostile observer')
|
||||
})
|
||||
await expect(table.put('a', { label: 'x', count: 1 })).resolves.toBeUndefined()
|
||||
// Commit survived intact on both planes, and well-behaved listeners
|
||||
// (registered before the thrower) still observed the event.
|
||||
expect(table.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(pool.media.get('demo')!.tables.get('items')!.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(changes).toHaveLength(1)
|
||||
// The chain is unpoisoned: subsequent writes proceed normally.
|
||||
await expect(table.delete('a')).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* In-memory {@link StorageBackend} test double implementing the full KvUnit
|
||||
* primitive set. Shared test infrastructure: the domain suite uses it to
|
||||
* exercise open/route/write semantics without touching disk, and the
|
||||
* workspace package's tests import it by relative path (it lives under
|
||||
* `tests/`, never `src/`, so it stays out of the published surface).
|
||||
*
|
||||
* Fidelity to the backend contract (`dsh-storage` `src/backend.ts`): version
|
||||
* stamping and `version-mismatch` on reopen, `malformed` never (memory cannot
|
||||
* corrupt), per-call atomicity trivially, `closed` after close, delete
|
||||
* idempotence. Media survive across backends through the shared `media` map
|
||||
* passed into the constructor, which simulates process restarts; stamp
|
||||
* `versions` directly to fabricate an on-medium version and force a
|
||||
* `version-mismatch` without a prior open.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { StorageError } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
|
||||
|
||||
/** One unit's medium: tables of records plus the global slot (`null` = never written). */
|
||||
export interface MemoryMedium {
|
||||
tables: Map<string, Map<string, unknown>>
|
||||
global: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared media pool. Construct one and hand it to several
|
||||
* {@link MemoryStorageBackend} instances to simulate reopening the same
|
||||
* medium after a restart; `versions` holds the stamped unit versions and is
|
||||
* writable by tests to inject a mismatching on-medium version, and
|
||||
* `failNextWrites` injects write-primitive failures.
|
||||
*/
|
||||
export class MemoryMediaPool {
|
||||
/** Unit name → its records; a missing entry is a never-materialized unit. */
|
||||
readonly media = new Map<string, MemoryMedium>()
|
||||
/** Unit name → stamped version; tests may pre-stamp to force `version-mismatch`. */
|
||||
readonly versions = new Map<string, number>()
|
||||
/**
|
||||
* When positive, that many subsequent write primitives (putRecord /
|
||||
* deleteRecord / setGlobal) reject without touching the medium, decrementing
|
||||
* per rejection. Negative-path seam: callers assert their state is
|
||||
* untouched after a durability failure.
|
||||
*/
|
||||
failNextWrites = 0
|
||||
|
||||
/** Consume one injected failure, throwing in a rejected write's place. */
|
||||
consumeInjectedFailure(): void {
|
||||
if (this.failNextWrites > 0) {
|
||||
this.failNextWrites -= 1
|
||||
throw new Error('injected write failure')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory KV unit over one pooled medium. */
|
||||
class MemoryKvUnit implements KvUnit {
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly pool: MemoryMediaPool,
|
||||
private readonly medium: MemoryMedium,
|
||||
private readonly descriptor: KvUnitDescriptor,
|
||||
private readonly onClose: () => void,
|
||||
) {}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new StorageError('closed', `memory unit '${this.descriptor.name}' is closed`)
|
||||
}
|
||||
}
|
||||
|
||||
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
|
||||
this.assertOpen()
|
||||
const tables: Record<string, Record<string, unknown>> = {}
|
||||
for (const table of this.descriptor.tables) {
|
||||
tables[table] = Object.fromEntries(this.medium.tables.get(table) ?? [])
|
||||
}
|
||||
return { tables, global: this.medium.global }
|
||||
}
|
||||
|
||||
async putRecord(table: string, key: string, value: unknown): Promise<void> {
|
||||
this.assertOpen()
|
||||
this.pool.consumeInjectedFailure()
|
||||
let records = this.medium.tables.get(table)
|
||||
if (records === undefined) {
|
||||
records = new Map()
|
||||
this.medium.tables.set(table, records)
|
||||
}
|
||||
records.set(key, value)
|
||||
}
|
||||
|
||||
async deleteRecord(table: string, key: string): Promise<void> {
|
||||
this.assertOpen()
|
||||
this.pool.consumeInjectedFailure()
|
||||
this.medium.tables.get(table)?.delete(key)
|
||||
}
|
||||
|
||||
async setGlobal(value: unknown): Promise<void> {
|
||||
this.assertOpen()
|
||||
this.pool.consumeInjectedFailure()
|
||||
this.medium.global = value
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory storage backend with a `kv` facet. Pass a shared
|
||||
* {@link MemoryMediaPool} to let a second instance reopen the same media;
|
||||
* omit it for a throwaway isolated pool.
|
||||
*/
|
||||
export class MemoryStorageBackend implements StorageBackend {
|
||||
readonly kv: KvFacet
|
||||
private readonly openUnits = new Set<string>()
|
||||
private closed = false
|
||||
|
||||
/**
|
||||
* @param pool - Media shared across instances; a fresh private pool when omitted.
|
||||
*/
|
||||
constructor(readonly pool: MemoryMediaPool = new MemoryMediaPool()) {
|
||||
this.kv = {
|
||||
open: async (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
|
||||
if (this.closed) {
|
||||
throw new StorageError('closed', 'memory backend is closed')
|
||||
}
|
||||
// Double-open is a caller bug per the backend contract; no dedicated
|
||||
// StorageError code exists for it, so a plain Error is correct.
|
||||
if (this.openUnits.has(descriptor.name)) {
|
||||
throw new Error(`memory unit '${descriptor.name}' is already open (double-open is a caller bug)`)
|
||||
}
|
||||
const stamped = this.pool.versions.get(descriptor.name)
|
||||
if (stamped === undefined) {
|
||||
this.pool.versions.set(descriptor.name, descriptor.version)
|
||||
} else if (stamped !== descriptor.version) {
|
||||
throw new StorageError(
|
||||
'version-mismatch',
|
||||
`memory unit '${descriptor.name}' is stamped v${stamped}, descriptor wants v${descriptor.version}`,
|
||||
)
|
||||
}
|
||||
let medium = this.pool.media.get(descriptor.name)
|
||||
if (medium === undefined) {
|
||||
medium = { tables: new Map(), global: null }
|
||||
this.pool.media.set(descriptor.name, medium)
|
||||
}
|
||||
this.openUnits.add(descriptor.name)
|
||||
return new MemoryKvUnit(this.pool, medium, descriptor, () => this.openUnits.delete(descriptor.name))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true
|
||||
this.openUnits.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import * as DomainInvariantCompanion from '@deepseek-ai/dsh-storage-domain/invariant'
|
||||
import { DomainFacility, defineDomain, domainTable } from '../src/index.ts'
|
||||
import type { DomainChanged } from '../src/events.ts'
|
||||
import { MemoryStorageBackend } from './helpers/memory-backend.ts'
|
||||
|
||||
const itemSchema = z.object({ n: z.number() })
|
||||
type Item = z.infer<typeof itemSchema>
|
||||
|
||||
const spec = defineDomain({
|
||||
name: 'inv',
|
||||
version: 1,
|
||||
global: { schema: itemSchema, initial: { n: 0 } },
|
||||
tables: { rows: domainTable<string, Item>(itemSchema) },
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(DomainInvariantCompanion)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
return { ctx, facility }
|
||||
}
|
||||
|
||||
const invariantViolation: unknown = expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-storage-domain',
|
||||
})
|
||||
|
||||
describe('domain change-event invariants', () => {
|
||||
it('accepts every write shape emitted by the real write paths', async () => {
|
||||
const { facility } = await setup()
|
||||
const domain = await facility.open(spec)
|
||||
const rows = domain.table('rows')
|
||||
await rows.put('a', { n: 1 })
|
||||
await rows.update('a', current => ({ n: current.n + 1 }))
|
||||
await expect(rows.delete('a')).resolves.toBe(true)
|
||||
await domain.global.set({ n: 5 })
|
||||
})
|
||||
|
||||
it('rejects an event for a domain that is not open', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(() => { ctx.emit('domain/changed', {
|
||||
domain: 'ghost', table: 'rows', key: 'a', operation: 'put', value: { n: 1 },
|
||||
}) }).toThrow(invariantViolation)
|
||||
})
|
||||
|
||||
it('rejects a put event whose value is not the in-memory record', async () => {
|
||||
const { ctx, facility } = await setup()
|
||||
const domain = await facility.open(spec)
|
||||
await domain.table('rows').put('a', { n: 1 })
|
||||
expect(() => { ctx.emit('domain/changed', {
|
||||
domain: 'inv', table: 'rows', key: 'a', operation: 'put', value: { n: 999 },
|
||||
}) }).toThrow(invariantViolation)
|
||||
})
|
||||
|
||||
it('rejects a deletion event while the record is still in memory', async () => {
|
||||
const { ctx, facility } = await setup()
|
||||
const domain = await facility.open(spec)
|
||||
await domain.table('rows').put('a', { n: 1 })
|
||||
expect(() => { ctx.emit('domain/changed', {
|
||||
domain: 'inv', table: 'rows', key: 'a', operation: 'deleted',
|
||||
}) }).toThrow(invariantViolation)
|
||||
})
|
||||
|
||||
it('rejects a global event whose value is not the in-memory global', async () => {
|
||||
const { ctx, facility } = await setup()
|
||||
await facility.open(spec)
|
||||
expect(() => { ctx.emit('domain/changed', {
|
||||
domain: 'inv', table: '', key: '', operation: 'put', value: { n: 42 },
|
||||
}) }).toThrow(invariantViolation)
|
||||
})
|
||||
|
||||
it('tolerates operations outside the closed union without failing falsely', async () => {
|
||||
const { ctx, facility } = await setup()
|
||||
const domain = await facility.open(spec)
|
||||
await domain.table('rows').put('a', { n: 1 })
|
||||
// Merge-hostile input: the closed union's satisfies-never default arm is
|
||||
// unreachable in typed code; an untyped emit must not crash the check.
|
||||
expect(() => { ctx.emit('domain/changed', {
|
||||
domain: 'inv', table: 'rows', key: 'a', operation: 'exotic',
|
||||
} as unknown as DomainChanged) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../storage"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-storage-json
|
||||
|
||||
JSON backend for the [storage hub](../storage/README.md): one human-readable `<unit>.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
## Model
|
||||
|
||||
- The in-memory unit state is authoritative; every write primitive republishes the whole file via temp-write + fsync + atomic `rename()` replace. A unit file is always the complete current net state — legibility is this backend's reason to exist; scale is the SQLite backend's job.
|
||||
- A missing file opens as an empty unit and materializes on the first write. A foreign or unparsable file rejects with `malformed-medium`; a stored version differing from the descriptor rejects with `version-mismatch` (no migration, pre-release stance).
|
||||
- Write ordering across calls belongs to the caller (the domain layer's write chain); each single call is atomic and durable once resolved.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `root` | string | required — no default (a cwd fallback would scatter files) | Directory holding unit files; created `0o700` on demand |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Stored domain records
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data behind `ctx.storage` for host-side consumers only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero live-request tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None — the backend never touches live request prefixes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Windows durability relies on libuv's `rename()` (`MoveFileExW` with replacement) without an explicit write-through flag; the session-log backend's stricter Win32 write-through publish helper is planned to move down here when the append-log facet lands (see the Agent Note's migration section).
|
||||
- No cross-process write locking: two processes writing the same root can interleave whole-file replacements (last write wins). Single-host-process deployments are the current consumer; the multi-process story is deferred per the Agent Note's out-of-scope table.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-storage-json",
|
||||
"description": "JSON file KV storage backend for the DeepSeek Harness storage hub",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-storage": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Atomic whole-file replacement for the JSON backend.
|
||||
*
|
||||
* Publish protocol: write a same-directory temp file, fsync it, then
|
||||
* `rename()` over the target. Rename is an atomic replace on POSIX and on
|
||||
* Windows (libuv maps it to `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`),
|
||||
* and replacement is the intended semantic here — unlike the session-log
|
||||
* backend's link()+unlink() no-clobber protocol, a unit file has exactly one
|
||||
* writer per process and last-write-wins is correct. After the rename the
|
||||
* parent directory is fsynced on POSIX so the new entry is crash-durable.
|
||||
* @module @deepseek-ai/dsh-storage-json/src/atomic
|
||||
*/
|
||||
|
||||
import { open, rename, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Durably replace `path` with `data`.
|
||||
* @param path - Absolute target file path.
|
||||
* @param data - Full new file content.
|
||||
* @returns resolution after the replacement is crash-durable.
|
||||
*/
|
||||
export async function writeAtomic(path: string, data: string): Promise<void> {
|
||||
const tmp = join(dirname(path), `.${randomUUID()}.tmp`)
|
||||
try {
|
||||
const handle = await open(tmp, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(data, 'utf8')
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(tmp, path)
|
||||
await fsyncDirectory(dirname(path))
|
||||
} catch (error) {
|
||||
await rm(tmp, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** fsync a POSIX directory so a just-renamed entry is crash-durable. */
|
||||
/* v8 ignore start -- Windows rejects O_RDONLY directory opens; POSIX coverage exercises this. */
|
||||
async function fsyncDirectory(path: string): Promise<void> {
|
||||
if (process.platform === 'win32') return
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* On-disk JSON unit format: the file is always the current net state, kept
|
||||
* human-readable (pretty-printed, stable key order from insertion) — that
|
||||
* legibility is this backend's reason to exist.
|
||||
* @module @deepseek-ai/dsh-storage-json/src/format
|
||||
*/
|
||||
|
||||
import { StorageError } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
|
||||
|
||||
/** In-memory authoritative state of one unit; the file is its projection. `global` is `null` until first written. */
|
||||
export interface UnitState {
|
||||
version: number
|
||||
global: unknown
|
||||
tables: Map<string, Map<string, unknown>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a unit state to file content.
|
||||
* @param name - Unit name, stamped into the header.
|
||||
* @param state - Authoritative in-memory state.
|
||||
* @returns pretty-printed JSON document with a trailing newline.
|
||||
*/
|
||||
export function serialize(name: string, state: UnitState): string {
|
||||
const tables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [table, records] of state.tables) {
|
||||
tables[table] = Object.fromEntries(records)
|
||||
}
|
||||
const document = {
|
||||
unit: { name, version: state.version },
|
||||
global: state.global,
|
||||
tables,
|
||||
}
|
||||
return `${JSON.stringify(document, null, 2)}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse file content into unit state, validating shape and version.
|
||||
* @param text - Raw file content.
|
||||
* @param descriptor - Expected identity; version mismatch rejects.
|
||||
* @returns the parsed state.
|
||||
*/
|
||||
export function parse(text: string, descriptor: KvUnitDescriptor): UnitState {
|
||||
let document: unknown
|
||||
try {
|
||||
document = JSON.parse(text)
|
||||
} catch (error) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': file is not valid JSON`, { cause: error })
|
||||
}
|
||||
if (typeof document !== 'object' || document === null) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': file is not a JSON object`)
|
||||
}
|
||||
const { unit, global: globalValue, tables } = document as Record<string, unknown>
|
||||
if (
|
||||
typeof unit !== 'object' || unit === null ||
|
||||
(unit as Record<string, unknown>)['name'] !== descriptor.name ||
|
||||
typeof (unit as Record<string, unknown>)['version'] !== 'number'
|
||||
) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': missing or foreign unit header`)
|
||||
}
|
||||
const version = (unit as Record<string, unknown>)['version'] as number
|
||||
if (version !== descriptor.version) {
|
||||
throw new StorageError(
|
||||
'version-mismatch',
|
||||
`unit '${descriptor.name}': stored version ${version} != expected ${descriptor.version}`,
|
||||
)
|
||||
}
|
||||
if (typeof tables !== 'object' || tables === null) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': tables is not an object`)
|
||||
}
|
||||
const state: UnitState = { version, global: globalValue ?? null, tables: new Map() }
|
||||
for (const table of descriptor.tables) {
|
||||
const records = (tables as Record<string, unknown>)[table]
|
||||
if (records === undefined) {
|
||||
state.tables.set(table, new Map())
|
||||
continue
|
||||
}
|
||||
if (typeof records !== 'object' || records === null || Array.isArray(records)) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': table '${table}' is not an object`)
|
||||
}
|
||||
state.tables.set(table, new Map(Object.entries(records as Record<string, unknown>)))
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* JSON storage backend: one human-readable file per unit under a configured
|
||||
* root, published by atomic whole-file rewrite. Registers as backend `json`
|
||||
* on the storage hub.
|
||||
* @module @deepseek-ai/dsh-storage-json
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
|
||||
import { openJsonUnit } from './unit.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'storage-json'
|
||||
/** The hub must exist before the backend can register. */
|
||||
export const inject = ['storage']
|
||||
|
||||
/**
|
||||
* 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 `<unit>.json` file per unit. */
|
||||
root: string
|
||||
}
|
||||
|
||||
/** Config schema. */
|
||||
export const Config: z<Config> = z.object({
|
||||
root: z.string().required(),
|
||||
})
|
||||
|
||||
/** JSON backend: owns the file-tree root and serves the `kv` facet. */
|
||||
export class JsonStorageBackend implements StorageBackend {
|
||||
private readonly open = new Map<string, KvUnit>()
|
||||
// Reserved synchronously at open() entry so a concurrent open of the same
|
||||
// unit fails, and close() can await opens still in flight.
|
||||
private readonly opening = new Map<string, Promise<KvUnit>>()
|
||||
private closed = false
|
||||
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
readonly kv: KvFacet = {
|
||||
// The body up to the first await runs synchronously, so the opening-slot
|
||||
// reservation below still excludes a concurrent open of the same unit.
|
||||
open: async (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
|
||||
if (this.closed) throw new StorageError('closed', 'json backend is closed')
|
||||
validateDescriptor(descriptor)
|
||||
if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) {
|
||||
// Double-open is a caller bug, not a medium condition.
|
||||
throw new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`)
|
||||
}
|
||||
const opening = this.openUnit(descriptor)
|
||||
this.opening.set(descriptor.name, opening)
|
||||
return opening.finally(() => this.opening.delete(descriptor.name))
|
||||
},
|
||||
}
|
||||
|
||||
private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
const path = join(this.root, `${descriptor.name}.json`)
|
||||
const unit = await openJsonUnit(descriptor, path, () => this.open.delete(descriptor.name))
|
||||
if (this.closed) {
|
||||
// The backend closed while this open was in flight: do not hand out a
|
||||
// live unit past close().
|
||||
await unit.close()
|
||||
throw new StorageError('closed', 'json backend is closed')
|
||||
}
|
||||
this.open.set(descriptor.name, unit)
|
||||
return unit
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.closed) {
|
||||
this.closed = true
|
||||
}
|
||||
await Promise.allSettled([...this.opening.values()])
|
||||
for (const unit of [...this.open.values()]) {
|
||||
await unit.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateDescriptor(descriptor: KvUnitDescriptor): void {
|
||||
if (!UNIT_NAME_RE.test(descriptor.name)) {
|
||||
throw new StorageError('malformed-medium', `invalid unit name '${descriptor.name}'`)
|
||||
}
|
||||
for (const table of descriptor.tables) {
|
||||
if (!UNIT_NAME_RE.test(table)) {
|
||||
throw new StorageError('malformed-medium', `invalid table name '${table}' in unit '${descriptor.name}'`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `json` backend on the storage hub.
|
||||
* @param ctx - Plugin context.
|
||||
* @param config - Validated configuration.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
const backend = new JsonStorageBackend(config.root)
|
||||
ctx.effect(() => {
|
||||
const unregister = ctx.storage.backend.register('json', backend)
|
||||
return async () => {
|
||||
unregister()
|
||||
await backend.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-json`.
|
||||
* @module @deepseek-ai/dsh-storage-json/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-storage-json'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'storage-json-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: correctness here is write-durability and
|
||||
* publish-then-reparse equivalence, which require medium round-trip tests
|
||||
* (the shared backend conformance suite); the backend exposes no continuously
|
||||
* observable in-process relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* One opened JSON unit. The in-memory state is authoritative; every write
|
||||
* primitive mutates it and republishes the whole file atomically. Writes are
|
||||
* NOT queued here — per the backend contract, write ordering belongs to the
|
||||
* caller (the domain layer's write chain); this unit only guarantees that
|
||||
* each single call publishes a complete, durable file.
|
||||
* @module @deepseek-ai/dsh-storage-json/src/unit
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { StorageError } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
|
||||
import { writeAtomic } from './atomic.ts'
|
||||
import { parse, serialize } from './format.ts'
|
||||
import type { UnitState } from './format.ts'
|
||||
|
||||
/**
|
||||
* Open (load or lazily create) one unit backed by `path`.
|
||||
* @param descriptor - Static identity and shape of the unit.
|
||||
* @param path - Absolute unit file path under the backend root.
|
||||
* @param onClose - Backend callback releasing the unit's open-slot.
|
||||
* @returns the opened unit.
|
||||
*/
|
||||
export async function openJsonUnit(
|
||||
descriptor: KvUnitDescriptor,
|
||||
path: string,
|
||||
onClose: () => void,
|
||||
): Promise<KvUnit> {
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(path, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
// Missing file = empty unit; materialization defers to the first write.
|
||||
}
|
||||
const state: UnitState =
|
||||
text === undefined
|
||||
? {
|
||||
version: descriptor.version,
|
||||
global: null,
|
||||
tables: new Map(descriptor.tables.map(table => [table, new Map<string, unknown>()])),
|
||||
}
|
||||
: parse(text, descriptor)
|
||||
return new JsonKvUnit(descriptor, path, state, onClose)
|
||||
}
|
||||
|
||||
class JsonKvUnit implements KvUnit {
|
||||
private closed = false
|
||||
/** In-flight publishes; close() drains them before releasing the unit. */
|
||||
private readonly inFlight = new Set<Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly descriptor: KvUnitDescriptor,
|
||||
private readonly path: string,
|
||||
private readonly state: UnitState,
|
||||
private readonly onClose: () => void,
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- async keeps the closed guard a rejection, not a synchronous throw
|
||||
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
|
||||
this.assertOpen()
|
||||
const tables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [table, records] of this.state.tables) {
|
||||
tables[table] = Object.fromEntries(records)
|
||||
}
|
||||
return { tables, global: this.state.global }
|
||||
}
|
||||
|
||||
async putRecord(table: string, key: string, value: unknown): Promise<void> {
|
||||
this.assertOpen()
|
||||
const records = this.records(table)
|
||||
const hadKey = records.has(key)
|
||||
const previous = records.get(key)
|
||||
records.set(key, value)
|
||||
// Roll back on a failed publish: memory is authoritative, so a rejected
|
||||
// write must not survive in memory (or ride along with the next publish).
|
||||
await this.publish().catch((error: unknown) => {
|
||||
if (hadKey) records.set(key, previous)
|
||||
else records.delete(key)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async deleteRecord(table: string, key: string): Promise<void> {
|
||||
this.assertOpen()
|
||||
const records = this.records(table)
|
||||
if (!records.has(key)) return
|
||||
const previous = records.get(key)
|
||||
records.delete(key)
|
||||
await this.publish().catch((error: unknown) => {
|
||||
records.set(key, previous)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async setGlobal(value: unknown): Promise<void> {
|
||||
this.assertOpen()
|
||||
if (!this.descriptor.hasGlobal) {
|
||||
throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`)
|
||||
}
|
||||
const previous = this.state.global
|
||||
this.state.global = value
|
||||
await this.publish().catch((error: unknown) => {
|
||||
this.state.global = previous
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
await Promise.allSettled(this.inFlight)
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
await Promise.allSettled(this.inFlight)
|
||||
this.onClose()
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`)
|
||||
}
|
||||
}
|
||||
|
||||
private records(table: string): Map<string, unknown> {
|
||||
const records = this.state.tables.get(table)
|
||||
if (!records) {
|
||||
throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private publish(): Promise<void> {
|
||||
const write = writeAtomic(this.path, serialize(this.descriptor.name, this.state))
|
||||
this.inFlight.add(write)
|
||||
// Swallow only on the tracking branch: the caller still awaits `write`
|
||||
// itself, so rejections stay observed exactly once.
|
||||
write.catch(() => {}).finally(() => this.inFlight.delete(write))
|
||||
return write
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user