Merge branch 'master' into worktree/default-pi-ai-providers
This commit is contained in:
58 files changed
+2215
-36
No files matched your search
+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 .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
|
||||
2026-07-27-session-projection-and-command-log.md: 51cc60208ecafd55738c12f1887056c7b0427117
|
||||
2026-07-27-session-projection-and-command-log.zh.md: 71f6f6ea944c7c1bdd7e560ec8f0dc2528522fc1
|
||||
2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5
|
||||
2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db
|
||||
+2
-2
@@ -51,7 +51,7 @@ declare module 'cordis' {
|
||||
|
||||
- Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
|
||||
- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
|
||||
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
|
||||
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
|
||||
- The package owns `./invariant` (every served key has a live registration).
|
||||
@@ -133,7 +133,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
|
||||
2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
|
||||
3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
|
||||
4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
|
||||
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
|
||||
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@ declare module 'cordis' {
|
||||
|
||||
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
|
||||
- **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
|
||||
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
|
||||
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
|
||||
- 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
|
||||
- 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
|
||||
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
|
||||
@@ -133,7 +133,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `
|
||||
2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。
|
||||
3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
|
||||
4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。
|
||||
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
|
||||
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
|
||||
|
||||
## 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 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Agent Note: Storage root placement and derived-medium recovery
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
|
||||
|
||||
**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
|
||||
|
||||
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
|
||||
|
||||
## Proposal
|
||||
|
||||
Two independent changes, one per gap.
|
||||
|
||||
### One global storage root, resolved once
|
||||
|
||||
- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
|
||||
- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
|
||||
- Pre-release stance applies: no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
|
||||
|
||||
### Declared derived media: reset instead of reject
|
||||
|
||||
- `DomainSpec` gains `recovery?: 'reject' | 'reset'` (default `'reject'`). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place. `session_projcache` declares `'reset'`; `workspace` stays on the default.
|
||||
- `KvFacet` gains one primitive: `destroy(descriptor): Promise<void>` — remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Like `open`, it is a backend storage primitive, not policy.
|
||||
- `DomainFacility.open`, when a spec declares `'reset'` and the open fails with exactly a damage-class error — `StorageError('version-mismatch' | 'malformed-medium')` or `DomainError('invalid-record')` — logs one warning naming the domain and the discarded medium, calls `destroy`, and opens again empty. Every other failure (`backend-not-found`, `facet-unsupported`, `already-open`, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop.
|
||||
- With this in place the cache domain spec's version field gains its intended meaning: bumping `version` (or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
|
||||
|
||||
**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
|
||||
|
||||
**Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures.
|
||||
|
||||
**Fall back to an ephemeral in-memory domain on damage** — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way.
|
||||
|
||||
**Rename the damaged medium aside (`<unit>.json.corrupt-<ts>`) instead of deleting** — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future *authoritative* domain ever wants reset semantics — which is exactly why `recovery` is per-spec.
|
||||
|
||||
**A blanket auto-reset for every domain (no spec field)** — rejected outright: `workspace.json` is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
|
||||
- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
|
||||
- The same damage to `workspace.json` still fails boot loudly.
|
||||
- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Auto-delete on a misclassified error destroys a healthy file.** Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open.
|
||||
- **Root relocation changes where existing checkouts look.** Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd `workspace.json`'s content.
|
||||
- **`destroy` is a new destructive primitive on the storage seam.** Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Agent Note:存储根目录落点与派生介质恢复
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
|
||||
|
||||
**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`(apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
|
||||
|
||||
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
|
||||
|
||||
## Proposal
|
||||
|
||||
两个独立改动,一个缺口一个。
|
||||
|
||||
### 全局唯一存储根,构造时 resolve 一次
|
||||
|
||||
- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。
|
||||
- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
|
||||
- 适用 pre-release 立场:不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
|
||||
|
||||
### 声明派生介质:损坏时重置而非拒绝
|
||||
|
||||
- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`)。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'`;`workspace` 保持默认。
|
||||
- `KvFacet` 增加一个原语:`destroy(descriptor): Promise<void>`——整体移除该 unit 的介质(json:删文件;sqlite:drop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。
|
||||
- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')` 或 `DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found`、`facet-unsupported`、`already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。
|
||||
- 有了这个,缓存域 spec 的 version 字段才获得其本意:bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
|
||||
|
||||
**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
|
||||
|
||||
**缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。
|
||||
|
||||
**损坏时退到内存态临时域**——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。
|
||||
|
||||
**把损坏介质改名旁置(`<unit>.json.corrupt-<ts>`)而非删除**——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个*权威*域想要重置语义,旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。
|
||||
|
||||
**所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
|
||||
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
|
||||
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
|
||||
- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。
|
||||
|
||||
## Risks
|
||||
|
||||
- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
|
||||
- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。
|
||||
- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。
|
||||
@@ -129,6 +129,16 @@
|
||||
- id: workspace
|
||||
name: '@deepseek-ai/dsh-workspace'
|
||||
|
||||
# Persisted projection cache: durable per-session checkpoints of every
|
||||
# registered projection unit (json backend → ./.storages/session_projcache.json,
|
||||
# beside workspace.json), throttled between the two mandatory points
|
||||
# (turn/end + detach), serving cold listings without full-log loads.
|
||||
- id: session-projection-cache
|
||||
name: '@deepseek-ai/dsh-session-projection-cache'
|
||||
config:
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
|
||||
@@ -77,6 +77,8 @@ flowchart LR
|
||||
pkg_session_projection["session-projection"]
|
||||
svc_sessionProjections["ctx.sessionProjections<br/>Session projection units"]
|
||||
pkg_host_apiproxy["host-apiproxy"]
|
||||
pkg_session_projection_cache["session-projection-cache"]
|
||||
svc_sessionProjectionCache["ctx.sessionProjectionCache<br/>Persisted projection cache"]
|
||||
svc_tui["ctx.tui<br/>Mounted-terminal interaction service"]
|
||||
pkg_skill["skill"]
|
||||
svc_skills["ctx.skills<br/>Skill provider registry"]
|
||||
@@ -184,6 +186,7 @@ flowchart LR
|
||||
pkg_session_persistence_jsonl --> svc_sessionPersistence
|
||||
pkg_session_persistence_sqlite --> svc_sessionPersistence
|
||||
pkg_session_projection --> svc_sessionProjections
|
||||
pkg_session_projection_cache --> svc_sessionProjectionCache
|
||||
pkg_session_query --> svc_sessionQuery
|
||||
pkg_session_query_sqlite --> svc_sessionQuery
|
||||
pkg_session_reference --> svc_sessionReferences
|
||||
@@ -261,6 +264,7 @@ flowchart LR
|
||||
svc_sessionPersistence --> pkg_session_query
|
||||
svc_sessionPersistence --> pkg_session_query_sqlite
|
||||
svc_sessionPersistence --> pkg_tool_bash
|
||||
svc_sessionProjectionCache --> pkg_host_apiproxy
|
||||
svc_sessionProjections --> pkg_host_apiproxy
|
||||
svc_sessionProjections --> pkg_session_title
|
||||
svc_sessionProjections --> pkg_tool_todo
|
||||
@@ -336,6 +340,7 @@ flowchart LR
|
||||
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
|
||||
| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui) | - | Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model. |
|
||||
| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. |
|
||||
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. |
|
||||
| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. |
|
||||
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
|
||||
|
||||
@@ -1048,6 +1048,27 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-projection-cache`
|
||||
|
||||
Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin config. Both throttle triggers are deployment choices with no
|
||||
* universally correct value, so the composition states them explicitly
|
||||
* (cordis.yml); the two mandatory write points (`turn/end` and session
|
||||
* disposal) are policy, not tunables, and always fire.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Committed events per session that force a durable checkpoint write between mandatory points. */
|
||||
writeEveryEvents: number
|
||||
/** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
|
||||
writeIntervalMs: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/session-projection/session-projection-cache/src/index.ts:42`](../packages/session-projection/session-projection-cache/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-query-sqlite`
|
||||
|
||||
Requires: `sessions`
|
||||
|
||||
@@ -1063,6 +1063,25 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven
|
||||
*/
|
||||
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
||||
|
||||
/**
|
||||
* Read the stored events from `fromSeq` onward — the read-from-seq
|
||||
* primitive for read models that resume from a watermark (e.g. a persisted
|
||||
* projection cache folding only the tail past its checkpoint). Like
|
||||
* {@link inspect} it is non-mutating and detached: no torn-tail truncation,
|
||||
* no synthetic closers, no coordinator-state publication; only events from
|
||||
* the valid contiguous stored prefix are returned, so a torn fragment never
|
||||
* reaches the caller. `fromSeq` at or beyond the stored prefix returns an
|
||||
* empty event list (never an error). Backends whose medium can seek by seq
|
||||
* (SQLite) read only the suffix; sequential media (JSONL, both encodings)
|
||||
* still parse the whole artifact and skip forward — the primitive bounds
|
||||
* what is RETURNED and refolded, not every backend's physical read.
|
||||
* @param id - the persisted session to read.
|
||||
* @param fromSeq - first event seq to include; a non-negative safe integer.
|
||||
* @param signal - optional cancellation for queued and backend read work.
|
||||
* @returns the header and the stored events with `seq >= fromSeq`.
|
||||
*/
|
||||
abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
||||
|
||||
/**
|
||||
* Lightweight listing from metadata, without a full-log parse.
|
||||
* @param signal - optional cancellation for backend listing work.
|
||||
@@ -1087,6 +1106,54 @@ Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../cor
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
|
||||
## `ctx.sessionProjectionCache` — `SessionProjectionCache`
|
||||
|
||||
The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* The zero-I/O listing read: whole values viewed straight from the stored
|
||||
* rows (version-matching keys only), each cut carried with its watermark
|
||||
* so a client value store can seed under its higher-seq-wins rule — as
|
||||
* stale as the last durable checkpoint but never wrong, and never from an
|
||||
* unrelated log (the caller's header is the identity witness). Fresher
|
||||
* paths (the history tail baseline, {@link coldSnapshot}) supersede these
|
||||
* values whenever a session is actually opened.
|
||||
* @param meta - the listed session's header (identity witness; no log read).
|
||||
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
|
||||
* `undefined` when no usable row exists for this lifecycle.
|
||||
*/
|
||||
cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
|
||||
|
||||
/**
|
||||
* Durably checkpoint one live session NOW (both mandatory points call
|
||||
* this; tests and carriers may too). The registry cut is snapshotted at
|
||||
* this boundary (states are live references), then the whole record is
|
||||
* replaced. NOT fail-soft — callers on the fail-soft paths contain it.
|
||||
* @param session - the live session to checkpoint.
|
||||
* @returns resolution after durability and event emission.
|
||||
*/
|
||||
async write(session: Session): Promise<void>
|
||||
|
||||
/**
|
||||
* Cold-read one persisted session's projections with zero full-log load:
|
||||
* cached rows + a persistence `readFrom` tail from the registry's restore
|
||||
* floor, refolded by the registry and written back (fail-soft) so the next
|
||||
* cold read starts closer. A cache row invalidated by a shrunk log
|
||||
* (crash-repair truncation) triggers one full re-read from seq 0 — the
|
||||
* ladder's slow rung, still no crash. Rejects when the session has no
|
||||
* persisted log (`not found` from the persistence seam).
|
||||
* @param id - the persisted session to read.
|
||||
* @param signal - optional cancellation for the persistence reads.
|
||||
* @returns the snapshot cut at the stored log end.
|
||||
*/
|
||||
async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
|
||||
```
|
||||
|
||||
Types: [Session](../core-data-structures/session.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`](../../packages/session-projection/session-projection-cache/src/index.ts)
|
||||
|
||||
## `ctx.sessionProjections` — `SessionProjectionRegistry`
|
||||
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
|
||||
@@ -1119,11 +1186,81 @@ onChanged(listener: ProjectionChangeListener): () => void
|
||||
* @returns the snapshot; `values` is empty when no unit is registered.
|
||||
*/
|
||||
snapshot(session: Session): ProjectionSnapshot
|
||||
|
||||
/**
|
||||
* State-level checkpoint of every registered unit for one session, read
|
||||
* from the watermark cache (missing cells fold lazily over the in-memory
|
||||
* log). This is the write side of the persisted projection cache: the
|
||||
* returned rows are the `(key → {ver, seq, val})` part of the durable
|
||||
* `(sessionId, key, ver, seq, val)`
|
||||
* rows. Every `val` is a DETACHED structured clone — never the live
|
||||
* cell reference: the watermark cache is this registry's authoritative
|
||||
* mutable state, and a caller reaching the live reference could corrupt
|
||||
* every subsequent snapshot and frame through it (plain JSON by the unit
|
||||
* contract, so the clone is total).
|
||||
* @param session - the session whose unit states are checkpointed.
|
||||
* @returns one row per registered key; empty when no unit is registered.
|
||||
*/
|
||||
checkpoint(session: Session): ProjectionCheckpoint
|
||||
|
||||
/**
|
||||
* The stored seq a {@link restore} tail read over `checkpoint` must start
|
||||
* at: one event BELOW the lowest usable watermark (a row is usable when
|
||||
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
|
||||
* pulls the floor to `0` — that key must refold the full log). The
|
||||
* one-below anchor is load-bearing: the tail then proves how far the
|
||||
* stored log still extends, so {@link restore} can detect a log that
|
||||
* shrank below a row's watermark (crash-repair truncation) instead of
|
||||
* serving the stale row as current — an empty tail read from the anchor
|
||||
* yields an end below every watermark and the restore rejects for a full
|
||||
* re-read.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns the seq to hand the persistence `readFrom`, or `undefined`
|
||||
* when no unit is registered (no read needed — {@link restore} would
|
||||
* serve empty values regardless).
|
||||
*/
|
||||
restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
|
||||
|
||||
/**
|
||||
* View a checkpoint's rows without any log read: for every registered
|
||||
* unit whose row's `ver` matches, serve the schema-validated
|
||||
* `view` of the stored state; mismatched or absent rows leave their key
|
||||
* absent (a cold or listing consumer treats it as not-yet-available and a
|
||||
* fuller read path refolds it). The zero-I/O rung of the read ladder —
|
||||
* values are as stale as their rows, never wrong.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns whole values per key with a usable row; empty when none.
|
||||
*/
|
||||
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
|
||||
|
||||
/**
|
||||
* Cold read: fold every registered unit over a stored log suffix, seeding
|
||||
* each from its checkpoint row when usable — the one read recipe (cached
|
||||
* state + forward tail replay + `view`) applied without a live `Session`.
|
||||
* Call with the events returned by a persistence
|
||||
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
|
||||
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
|
||||
* so a shrunk log is detected here. A row is usable iff its
|
||||
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
|
||||
* (`seq >= baseSeq - 1`), and it does not claim events past the
|
||||
* supplied end (`seq <= endSeq`); an unusable row is discarded
|
||||
* and its key refolds from `init` — which is only sound over the full
|
||||
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
|
||||
* from seq 0, e.g. after a crash-repair truncation shrank the log below
|
||||
* a row's watermark).
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
||||
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
*/
|
||||
restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
```
|
||||
|
||||
Types: [Session](../core-data-structures/session.md)
|
||||
Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/session-projection/session-projection/src/index.ts:136`](../../packages/session-projection/session-projection/src/index.ts)
|
||||
Source: [`packages/session-projection/session-projection/src/index.ts:156`](../../packages/session-projection/session-projection/src/index.ts)
|
||||
|
||||
## `ctx.sessionQuery` — `SessionQueryService` (abstract seam)
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../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:58`](../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:71`](../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), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../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-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
|
||||
@@ -211,6 +211,7 @@ flowchart TD
|
||||
end
|
||||
subgraph group_session_projection["packages/session-projection"]
|
||||
pkg_session_projection["session-projection"]
|
||||
pkg_session_projection_cache["session-projection-cache"]
|
||||
end
|
||||
subgraph group_storage["packages/storage"]
|
||||
pkg_storage["storage"]
|
||||
@@ -502,6 +503,11 @@ flowchart TD
|
||||
pkg_pty --> pkg_invariants
|
||||
pkg_scripts --> pkg_app_boot
|
||||
pkg_scripts --> pkg_invariants
|
||||
pkg_session_projection_cache --> pkg_invariants
|
||||
pkg_session_projection_cache --> pkg_session
|
||||
pkg_session_projection_cache --> pkg_session_persistence
|
||||
pkg_session_projection_cache --> pkg_session_projection
|
||||
pkg_session_projection_cache --> pkg_storage_domain
|
||||
pkg_tasks --> pkg_agent
|
||||
pkg_tasks --> pkg_brand
|
||||
pkg_tasks --> pkg_invariants
|
||||
@@ -1003,6 +1009,7 @@ flowchart TD
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
|
||||
| [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`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) |
|
||||
|
||||
@@ -212,6 +212,19 @@ export class SessionManager {
|
||||
session.handleBlank(s.blank)
|
||||
session.handleRunning(s.running)
|
||||
}
|
||||
// Seed each row's projection baseline into the per-session value
|
||||
// store (cold titles surface without opening the session). Per-key
|
||||
// apply, not seed(): the list block is a partial baseline — the
|
||||
// cold cache serves only version-matching keys — so an absent key
|
||||
// must not clear; higher-seq-wins still keeps a stale list block
|
||||
// from overwriting a newer push frame or tail baseline.
|
||||
for (const s of result.value.items) {
|
||||
const block = s.projections
|
||||
if (block === undefined) continue
|
||||
const store = this.projectionStore(s.sessionId)
|
||||
const values = block.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
|
||||
}
|
||||
} else {
|
||||
this.listState = 'error'
|
||||
this.listError = result.error
|
||||
|
||||
@@ -160,6 +160,28 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push-newer' as never,
|
||||
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
|
||||
})
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
|
||||
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
|
||||
] as never[],
|
||||
}))
|
||||
await manager.refreshList()
|
||||
const items = manager.getListSnapshot().items
|
||||
// Cold row: title surfaces straight from the list block — no open, no history.
|
||||
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
|
||||
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
|
||||
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
|
||||
})
|
||||
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
|
||||
@@ -524,6 +524,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
|
||||
jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
|
||||
jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Like\n * {@link inspect} it is non-mutating and detached: no torn-tail truncation,\n * no synthetic closers, no coordinator-state publication; only events from\n * the valid contiguous stored prefix are returned, so a torn fragment never\n * reaches the caller. `fromSeq` at or beyond the stored prefix returns an\n * empty event list (never an error). Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract list(signal?: AbortSignal): Promise<SessionHeader[]>',
|
||||
jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @param signal - optional cancellation for backend listing work.\n * @returns one header per materialized session.\n */',
|
||||
@@ -534,6 +538,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionProjectionCache',
|
||||
summary: 'The persisted projection cache service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined',
|
||||
jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), each cut carried with its watermark\n * so a client value store can seed under its higher-seq-wins rule — as\n * stale as the last durable checkpoint but never wrong, and never from an\n * unrelated log (the caller\'s header is the identity witness). Fresher\n * paths (the history tail baseline, {@link coldSnapshot}) supersede these\n * values whenever a session is actually opened.\n * @param meta - the listed session\'s header (identity witness; no log read).\n * @returns the cut (`asOfSeq` = lowest served-row watermark), or\n * `undefined` when no usable row exists for this lifecycle.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async write(session: Session): Promise<void>',
|
||||
jsDoc: '/**\n * Durably checkpoint one live session NOW (both mandatory points call\n * this; tests and carriers may too). The registry cut is snapshotted at\n * this boundary (states are live references), then the whole record is\n * replaced. NOT fail-soft — callers on the fail-soft paths contain it.\n * @param session - the live session to checkpoint.\n * @returns resolution after durability and event emission.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>',
|
||||
jsDoc: '/**\n * Cold-read one persisted session\'s projections with zero full-log load:\n * cached rows + a persistence `readFrom` tail from the registry\'s restore\n * floor, refolded by the registry and written back (fail-soft) so the next\n * cold read starts closer. A cache row invalidated by a shrunk log\n * (crash-repair truncation) triggers one full re-read from seq 0 — the\n * ladder\'s slow rung, still no crash. Rejects when the session has no\n * persisted log (`not found` from the persistence seam).\n * @param id - the persisted session to read.\n * @param signal - optional cancellation for the persistence reads.\n * @returns the snapshot cut at the stored log end.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionProjections',
|
||||
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
|
||||
@@ -550,6 +572,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'snapshot(session: Session): ProjectionSnapshot',
|
||||
jsDoc: '/**\n * One consistent cut over every registered unit for one session, read from\n * the watermark cache (missing cells fold lazily over the in-memory log).\n * Fully synchronous — every value and `asOfSeq` reflect the same log\n * position. Each value passes its unit\'s schema before leaving.\n * @param session - the session whose projection values are read.\n * @returns the snapshot; `values` is empty when no unit is registered.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'checkpoint(session: Session): ProjectionCheckpoint',
|
||||
jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {ver, seq, val})` part of the durable\n * `(sessionId, key, ver, seq, val)`\n * rows. Every `val` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined',
|
||||
jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */',
|
||||
},
|
||||
{
|
||||
signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>',
|
||||
jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `ver` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
|
||||
jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq`\n * (`seq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`seq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1855,6 +1893,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ProjectionChangeListener',
|
||||
declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: number) => void;',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionCheckpoint',
|
||||
declaration: 'export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>;',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionCheckpointRow',
|
||||
declaration: 'export interface ProjectionCheckpointRow {\n ver: number;\n seq: number;\n val: unknown;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionDefinition',
|
||||
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {\n key: K;\n schema: ZodType<SessionProjectionMap[K]>;\n init(): S;\n apply(state: S, event: SessionEvent): S;\n view(state: S): SessionProjectionMap[K];\n stateVersion: number;\n}',
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
} from './api/index.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
@@ -297,6 +299,28 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u
|
||||
return registry.snapshot(agent.session)
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection baseline of one session.list row, fail-soft: attached
|
||||
* sessions cut the registry's live watermark cache; cold sessions view the
|
||||
* persisted projection cache's identity-checked stored rows (zero log loads
|
||||
* either way — the listing use case the cache exists for). The block shape
|
||||
* (values + asOfSeq) matches the history tail's, so a client seeds its
|
||||
* value store under the same higher-seq-wins rule. Any failure — and an
|
||||
* empty value set — yields an absent block: a listing without projections
|
||||
* is degraded, never broken.
|
||||
*/
|
||||
function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
const block = session !== undefined
|
||||
? ctx.get('sessionProjections')?.snapshot(session)
|
||||
: ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
|
||||
return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the cold-resume path when the id names no servable session
|
||||
* (absent from the store, or a pre-project legacy log without a cwd).
|
||||
@@ -654,13 +678,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
async list(request) {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
const projections = listProjectionsFor(ctx, session.header, session)
|
||||
return {
|
||||
...summarize(session, agent?.status === 'running'),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
items.push(...await Promise.all(cold.map(async (meta) => {
|
||||
// Cold rows read the persisted projection cache only — never a
|
||||
// log load; a session without a cache row simply has no column.
|
||||
const projections = listProjectionsFor(ctx, meta, undefined)
|
||||
return {
|
||||
...await summarizeCold(persistence, meta),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
})))
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return ok(request, { items })
|
||||
|
||||
@@ -37,7 +37,7 @@ export const sessionEventSchema = z.object({
|
||||
surfaceOp: z.unknown().optional(),
|
||||
}) as unknown as z.ZodType<SessionEvent>
|
||||
|
||||
/** SessionSummary row of session.list. */
|
||||
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
|
||||
export const sessionSummarySchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
updatedAt: z.number(),
|
||||
@@ -45,7 +45,8 @@ export const sessionSummarySchema = z.object({
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SessionSummary>>
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
|
||||
/** session.list request payload (cursor is a reserved seat, unimplemented in v1). */
|
||||
export const sessionListRequestSchema = z.object({
|
||||
@@ -53,9 +54,9 @@ export const sessionListRequestSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.list'>>>
|
||||
|
||||
/** session.list response value. */
|
||||
export const sessionListValueSchema = z.object({
|
||||
export const sessionListValueSchema: z.ZodType<Wire<ResponseValue<'session.list'>>> = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
})
|
||||
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
|
||||
@@ -143,6 +143,18 @@ export interface SessionSummary {
|
||||
parentSessionId?: SessionId
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Projection baseline for this row, with zero log loads: attached sessions
|
||||
* read the registry's live watermark cut; cold sessions read the persisted
|
||||
* projection cache's stored rows — as stale as that session's last durable
|
||||
* checkpoint (`asOfSeq` says exactly how stale), never wrong, and directly
|
||||
* seedable into the client's per-session value store under its
|
||||
* higher-seq-wins rule (a list baseline can never overwrite a newer push
|
||||
* frame). Absent when no value is available (no registry, no cache row for
|
||||
* a cold session, or a fail-soft cache read miss); a listing client treats
|
||||
* absence as "no title yet", exactly like a blank session.
|
||||
*/
|
||||
projections?: SessionProjectionsBlock
|
||||
}
|
||||
|
||||
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
|
||||
|
||||
@@ -13,7 +13,7 @@ import { z } from 'zod'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -125,6 +125,82 @@ describe('session.history projections block', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.list projections column', () => {
|
||||
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
||||
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
it('omits the column entirely when no registry is mounted', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row).toBeDefined()
|
||||
expect(row !== undefined && 'projections' in row).toBe(false)
|
||||
})
|
||||
|
||||
it('serves cold rows from the persisted projection cache with zero log loads', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const coldId = SessionId('session-cold-listing')
|
||||
const load = () => { throw new Error('list must not load event logs') }
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
||||
locate: () => undefined,
|
||||
load,
|
||||
inspect: load,
|
||||
readFrom: load,
|
||||
} as never)
|
||||
ctx.provide('sessionProjectionCache', {
|
||||
// The carrier hands the listed header through as the identity witness.
|
||||
cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
|
||||
(meta.id === coldId && meta.createdAt === 5
|
||||
? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
|
||||
: undefined),
|
||||
} as never)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === coldId)
|
||||
expect(row?.running).toBe(false)
|
||||
expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } })
|
||||
})
|
||||
|
||||
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const coldId = SessionId('session-cold-uncached')
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === coldId)
|
||||
expect(row).toBeDefined()
|
||||
expect(row !== undefined && 'projections' in row).toBe(false)
|
||||
})
|
||||
|
||||
it('a throwing column read degrades that row, never the listing', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register({
|
||||
...lastUserUnit(),
|
||||
view: () => { throw new Error('unit exploded') },
|
||||
})
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row).toBeDefined()
|
||||
expect(row !== undefined && 'projections' in row).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/projection push frame', () => {
|
||||
/** Drain frames until `count` session/projection frames arrived. */
|
||||
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
|
||||
+3
@@ -22,6 +22,9 @@ class TestPersistence extends SessionPersistence {
|
||||
inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
readFrom(_id: SessionId, _fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
|
||||
listSnapshots(): Promise<never[]> { return Promise.resolve([]) }
|
||||
}
|
||||
|
||||
@@ -134,6 +134,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
return this.coordinator.inspect(id, signal)
|
||||
}
|
||||
|
||||
// JSONL is sequential media: no loadStoredFrom hook, so the coordinator
|
||||
// parses the stored prefix (both encodings) and skips forward to fromSeq.
|
||||
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return this.coordinator.readFrom(id, fromSeq, signal)
|
||||
}
|
||||
|
||||
// One method serves both public `list` and the backend hook; delegating it to
|
||||
// the coordinator would call this hook recursively.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
|
||||
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
|
||||
type StoredPrefix,
|
||||
type StoredPrefix, type StoredSuffix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
@@ -161,6 +161,10 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.coordinator.inspect(id, signal)
|
||||
}
|
||||
|
||||
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return this.coordinator.readFrom(id, fromSeq, signal)
|
||||
}
|
||||
|
||||
// One method serves both public `list` and the backend hook; delegating it to
|
||||
// the coordinator would call this hook recursively.
|
||||
|
||||
@@ -171,6 +175,26 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.readPrefix(id, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the
|
||||
* read scales with the suffix, not the log. Torn rows past the preserved
|
||||
* region are dropped, never repaired (non-mutating read).
|
||||
*/
|
||||
async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined> {
|
||||
signal?.throwIfAborted()
|
||||
await this.ready
|
||||
signal?.throwIfAborted()
|
||||
const row = this.rowFor(id)
|
||||
if (row === undefined) return undefined
|
||||
const meta = rowToMeta(row)
|
||||
const eventRows = this.db
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
|
||||
.all(id, fromSeq) as unknown as EventRow[]
|
||||
signal?.throwIfAborted()
|
||||
const { preserved } = scanRows(eventRows, fromSeq)
|
||||
return { meta, events: preserved }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a session's row + ordered events into a {@link StoredPrefix}. The
|
||||
* torn-tail marker is the seq from which a never-committed tail must be deleted
|
||||
|
||||
@@ -213,10 +213,12 @@ export function rowToEvent(row: EventRow): SessionEvent {
|
||||
* the committed region rejects.
|
||||
*
|
||||
* @param rows - one session's event rows, ordered by seq ascending.
|
||||
* @param base - the seq the first row is expected to carry; `0` for a whole
|
||||
* log, the requested `fromSeq` for a suffix read (`loadStoredFrom`).
|
||||
* @returns the preserved event prefix, plus `tornFrom` — the seq the physical
|
||||
* delete starts at — when a torn tail exists.
|
||||
*/
|
||||
export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
|
||||
export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } {
|
||||
// Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
|
||||
// (The seq/type COLUMNS are always present even when `data` is corrupt.)
|
||||
interface Parsed { ok: boolean; event?: SessionEvent }
|
||||
@@ -244,8 +246,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]
|
||||
if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
|
||||
break // torn tail fragment after the last turn/end — stop, tolerate
|
||||
}
|
||||
if (p.event.seq !== i) {
|
||||
if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`)
|
||||
if (p.event.seq !== base + i) {
|
||||
if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`)
|
||||
break // gap after the last turn/end — torn tail, stop
|
||||
}
|
||||
preserved.push(p.event)
|
||||
@@ -253,5 +255,5 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]
|
||||
|
||||
// Any rows past the preserved prefix are a never-committed torn tail; their
|
||||
// first seq is the deletion point for load's physical repair.
|
||||
return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved }
|
||||
return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved }
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md
|
||||
README.md: 3617305d0343ab4c0d9d802669a3c4f964271dc7
|
||||
README.zh.md: ffa86b0093331306d524a590364fac527a2e5071
|
||||
README.md: a8a4f14c8613a7e51bcf467e816b7f7bdb7ea80b
|
||||
README.zh.md: 369e8a01b8ac411ed9acfbac86b9b34db8037c1f
|
||||
@@ -15,6 +15,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
|
||||
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix; sequential backends (JSONL) still parse the whole artifact and skip forward — the primitive bounds what is returned and refolded, not every backend's physical read. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
|
||||
|
||||
@@ -45,6 +46,7 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
|
||||
|---|---|
|
||||
| `name` | Backend label for the dispose-failure `AggregateError`. |
|
||||
| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. |
|
||||
| `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. |
|
||||
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
|
||||
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
|
||||
| `list(signal?)` | List all stored metadata, observing optional cancellation. |
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
|
||||
| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态)。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀;顺序后端(JSONL)仍解析整个产物并向前跳过——原语约束的是返回和重折叠的量,不是每个后端的物理读取。用于从水位续折尾部的 checkpoint 消费者(例如持久投影缓存)。 |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 |
|
||||
|
||||
@@ -45,6 +46,7 @@
|
||||
|---|---|
|
||||
| `name` | dispose 失败 `AggregateError` 的后端标签。 |
|
||||
| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于 resume/load、非变更 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 |
|
||||
| `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非变更、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 |
|
||||
| `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 |
|
||||
| `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和实时接管(仅截断)使用。 |
|
||||
| `list(signal?)` | 列出全部已存储元数据,观察可选取消。 |
|
||||
|
||||
@@ -25,6 +25,17 @@ export interface StoredPrefix<TornMarker = unknown> {
|
||||
tornMarker?: TornMarker
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored session's header plus the events at or past a requested seq — the
|
||||
* return shape of the optional seek-capable
|
||||
* {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no
|
||||
* torn marker: there is nothing to repair.
|
||||
*/
|
||||
export interface StoredSuffix {
|
||||
meta: SessionHeader
|
||||
events: SessionEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage seam between {@link PersistenceCoordinator} and a concrete
|
||||
* backend: the minimal set of durable primitives the orchestration calls. A
|
||||
@@ -50,6 +61,22 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
*/
|
||||
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined>
|
||||
|
||||
/**
|
||||
* Optional seek-capable suffix read behind the service's `readFrom`: return
|
||||
* the header plus the stored events with `seq >= fromSeq` without reading
|
||||
* the whole log. A backend whose medium can address events by seq (SQLite)
|
||||
* implements this so `readFrom` scales with the suffix; sequential backends
|
||||
* omit it and the coordinator falls back to {@link loadStored} plus a
|
||||
* forward skip. Non-mutating (no truncation, no closers). Validation of the
|
||||
* region strictly below `fromSeq` is limited to seq contiguity — the
|
||||
* service contract scopes this read to the suffix.
|
||||
* @param id - persisted session id to resolve.
|
||||
* @param fromSeq - first event seq to include (non-negative safe integer,
|
||||
* validated by the coordinator before this hook runs).
|
||||
* @param signal - optional cancellation for backend read work.
|
||||
*/
|
||||
loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined>
|
||||
|
||||
/**
|
||||
* Durably append a CONTIGUOUS batch, lazily materializing the session first
|
||||
* when `!isMaterialized`. The materialize-write and the first event batch MUST
|
||||
@@ -457,6 +484,52 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored events from `fromSeq` onward, detached and non-mutating
|
||||
* (the read-from-seq primitive behind the service's `readFrom`). Runs on
|
||||
* the same per-id chain as writes; a backend with the seek-capable
|
||||
* {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix,
|
||||
* every other backend reads its stored prefix and skips forward here.
|
||||
* @param id - persisted session to read.
|
||||
* @param fromSeq - first event seq to include; a non-negative safe integer.
|
||||
* @param signal - optional cancellation for queued and backend read work.
|
||||
* @returns stored header and the valid stored events with `seq >= fromSeq`.
|
||||
*/
|
||||
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) {
|
||||
return Promise.reject(new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`))
|
||||
}
|
||||
const retired = Promise.resolve(this.retirements.get(id))
|
||||
const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false)
|
||||
return waited.then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal))
|
||||
}
|
||||
|
||||
private async readFromCore(
|
||||
id: SessionId,
|
||||
fromSeq: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
signal?.throwIfAborted()
|
||||
if (this.backend.loadStoredFrom !== undefined) {
|
||||
let suffix: StoredSuffix | undefined
|
||||
try {
|
||||
suffix = await this.backend.loadStoredFrom(id, fromSeq, signal)
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
throw error
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
if (suffix === undefined) throw new Error(`session "${id}" not found`)
|
||||
this.assertStoredId(id, suffix.meta)
|
||||
this.assertVersion(suffix.meta)
|
||||
assertSupportedEvents(suffix.events, id)
|
||||
return { meta: structuredClone(suffix.meta), events: structuredClone(suffix.events) }
|
||||
}
|
||||
const whole = await this.inspectCore(id, signal)
|
||||
// Sequential fallback: contiguous seqs from 0 make the suffix an index slice.
|
||||
return { meta: whole.meta, events: whole.events.slice(fromSeq) }
|
||||
}
|
||||
|
||||
private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const stored = await this.backend.loadStored(id)
|
||||
if (stored === undefined) throw new Error(`session "${id}" not found`)
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface SessionPersistenceSnapshot {
|
||||
|
||||
// The backend-agnostic write-path orchestration first-party backends compose.
|
||||
export { PersistenceCoordinator } from './coordinator.ts'
|
||||
export type { PersistenceBackend, StoredPrefix } from './coordinator.ts'
|
||||
export type { PersistenceBackend, StoredPrefix, StoredSuffix } from './coordinator.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -114,6 +114,26 @@ export abstract class SessionPersistence extends Service {
|
||||
*/
|
||||
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
||||
|
||||
/**
|
||||
* Read the stored events from `fromSeq` onward — the read-from-seq
|
||||
* primitive for read models that resume from a watermark (e.g. a persisted
|
||||
* projection cache folding only the tail past its checkpoint). Like
|
||||
* {@link inspect} it is non-mutating and detached: no torn-tail truncation,
|
||||
* no synthetic closers, no coordinator-state publication; only events from
|
||||
* the valid contiguous stored prefix are returned, so a torn fragment never
|
||||
* reaches the caller. `fromSeq` at or beyond the stored prefix returns an
|
||||
* empty event list (never an error). Backends whose medium can seek by seq
|
||||
* (SQLite) read only the suffix; sequential media (JSONL, both encodings)
|
||||
* still parse the whole artifact and skip forward — the primitive bounds
|
||||
* what is RETURNED and refolded, not every backend's physical read.
|
||||
* @param id - the persisted session to read.
|
||||
* @param fromSeq - first event seq to include; a non-negative safe integer.
|
||||
* @param signal - optional cancellation for queued and backend read work.
|
||||
* @returns the header and the stored events with `seq >= fromSeq`.
|
||||
*/
|
||||
abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal):
|
||||
Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
||||
|
||||
/**
|
||||
* Lightweight listing from metadata, without a full-log parse.
|
||||
* @param signal - optional cancellation for backend listing work.
|
||||
|
||||
@@ -289,6 +289,43 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
|
||||
.rejects.toBe(reason)
|
||||
await expect(persistence.readFrom(SessionId('cancelled-read-from'), 0, controller.signal))
|
||||
.rejects.toBe(reason)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('readFrom returns exactly the stored suffix from the requested seq, without mutating the log', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('read-from', '/work')
|
||||
const log = oneTurnLog()
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const whole = await persistence.readFrom(m.id, 0)
|
||||
expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' })
|
||||
expect(whole.events).toEqual(log)
|
||||
|
||||
const suffix = await persistence.readFrom(m.id, 3)
|
||||
expect(suffix.events).toEqual(log.slice(3))
|
||||
expect(suffix.events[0]?.seq).toBe(3)
|
||||
|
||||
// At/past the stored end: an empty tail, never an error.
|
||||
await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] })
|
||||
await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] })
|
||||
|
||||
// Non-mutating: an interrupted-turn log is served as stored, no closers.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
])
|
||||
const tail = await persistence.readFrom(m.id, 6)
|
||||
expect(tail.events.map(event => event.type)).toEqual(['turn/start'])
|
||||
|
||||
await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found')
|
||||
await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer')
|
||||
await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer')
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
|
||||
@@ -99,6 +99,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
|
||||
return this.coordinator.inspect(id, signal)
|
||||
}
|
||||
|
||||
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return this.coordinator.readFrom(id, fromSeq, signal)
|
||||
}
|
||||
|
||||
// --- PersistenceBackend hooks (the Map storage primitives) ---
|
||||
|
||||
// A Map-backed store has no torn tails, so `tornMarker` is never set.
|
||||
@@ -157,6 +161,13 @@ class ControlledBackend implements PersistenceBackend<never> {
|
||||
repairAttempts = 0
|
||||
beforeAppend?: (attempt: number) => Promise<void>
|
||||
beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
|
||||
/** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */
|
||||
seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<StoredPrefix<never> | undefined>
|
||||
|
||||
loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> {
|
||||
if (this.seekHook === undefined) throw new Error('seekHook not configured for this test')
|
||||
return this.seekHook(id, fromSeq, signal)
|
||||
}
|
||||
|
||||
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> {
|
||||
await this.beforeLoadStored?.(++this.loadAttempts, signal)
|
||||
@@ -453,6 +464,58 @@ describe('PersistenceCoordinator observation cancellation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
const id = SessionId('seek-read-from')
|
||||
const log = oneTurnLog()
|
||||
backend.store.set(id, { meta: meta(id), events: log })
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
|
||||
try {
|
||||
// Happy path through the hook: only the suffix comes back, detached.
|
||||
backend.seekHook = async (hookId, fromSeq) => {
|
||||
const entry = backend.store.get(hookId)
|
||||
if (entry === undefined) return undefined
|
||||
return { meta: structuredClone(entry.meta), events: entry.events.filter(e => e.seq >= fromSeq) }
|
||||
}
|
||||
const suffix = await coordinator.readFrom(id, 3)
|
||||
expect(suffix.events).toEqual(log.slice(3))
|
||||
// The hook's undefined is the seam's not-found.
|
||||
await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found')
|
||||
|
||||
// A hook failure with no cancellation in play propagates as-is.
|
||||
const hookFailure = new Error('seek backend exploded')
|
||||
backend.seekHook = () => Promise.reject(hookFailure)
|
||||
await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure)
|
||||
|
||||
// A hook failure after cancellation surfaces the caller's abort reason,
|
||||
// not the backend's internal teardown error. The abort fires only once
|
||||
// the hook is provably entered, so the failure exercises the catch (not
|
||||
// the pre-invocation throwIfAborted).
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('read-from cancelled mid-hook')
|
||||
let hookEntered = false
|
||||
backend.seekHook = async (_hookId, _fromSeq, signal) => {
|
||||
hookEntered = true
|
||||
await new Promise<void>((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) })
|
||||
throw new Error('backend teardown after abort')
|
||||
}
|
||||
const pending = coordinator.readFrom(id, 0, controller.signal)
|
||||
const observed = pending.catch((error: unknown) => error)
|
||||
await vi.waitFor(() => { expect(hookEntered).toBe(true) })
|
||||
controller.abort(reason)
|
||||
expect(await observed).toBe(reason)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a cancelled inspect while an in-flight retirement drain is still pending', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -537,6 +600,66 @@ describe('PersistenceCoordinator retirement', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a superseded retirement leaves the successor lifecycle\'s pending drain in place', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
const internals = coordinator as unknown as CoordinatorInternals
|
||||
const readGate = Promise.withResolvers<boolean>()
|
||||
|
||||
try {
|
||||
const id = SessionId('superseded-retirement')
|
||||
// First lifecycle: unmaterialized (zero events), so a same-id successor
|
||||
// may legally reclaim the abandoned id later.
|
||||
let first!: Session
|
||||
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
first = inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
await ctx.sessions.flush(first)
|
||||
|
||||
// Occupy the per-id serialize chain with a gated read: everything the
|
||||
// two retirements queue stays pending behind it. (Attempt counting
|
||||
// starts here — an absent beforeLoadStored short-circuits the optional
|
||||
// call without evaluating its ++ argument.)
|
||||
backend.beforeLoadStored = async (attempt) => {
|
||||
if (attempt === 1) await readGate.promise
|
||||
}
|
||||
const parked = coordinator.inspect(id).catch((error: unknown) => error)
|
||||
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
|
||||
|
||||
// First retirement queues behind the gate and stays pending.
|
||||
await firstFiber.dispose()
|
||||
await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(true) })
|
||||
const firstRetirement = internals.retirements.get(id)
|
||||
|
||||
// Successor lifecycle retires while the first drain is still in flight:
|
||||
// retire() replaces the map entry synchronously.
|
||||
const secondFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
await secondFiber.dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect(internals.retirements.get(id)).not.toBe(firstRetirement)
|
||||
})
|
||||
|
||||
// Release the chain: the first drain settles and its forget() must not
|
||||
// delete the successor's entry (exact-entry guard); the successor's own
|
||||
// forget() then clears the map.
|
||||
readGate.resolve(true)
|
||||
expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed
|
||||
await firstRetirement
|
||||
await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) })
|
||||
} finally {
|
||||
readGate.resolve(true)
|
||||
await backendFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('a replacement queued before retirement cleanup still collides with the live owner', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-projection/README.md
|
||||
README.md: 81c67d56e136ba4853e86d889b485d4df80ac1fe
|
||||
README.zh.md: 72e23b78a48a989f355f9be3d34d81a440ca1d04
|
||||
README.md: ae80a905705d205adb4a1ee66c72fa28d0d8b6d6
|
||||
README.zh.md: 97e25dd16caeeb444f5f5309eed3341d422fa1b1
|
||||
@@ -7,3 +7,4 @@ Session-projection capability family: the seam through which domain host plugins
|
||||
| Package | ctx key | Role |
|
||||
|---|---|---|
|
||||
| [`session-projection`](session-projection/README.md) | `sessionProjections` | The interface package: the merge-extensible `SessionProjectionMap` type table, the `ProjectionDefinition` unit contract, and the eagerly driven registry carriers read synchronously |
|
||||
| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | Persisted projection cache: durable per-session unit checkpoints over the domain data form, throttled write-behind with mandatory turn/end + detach points, and the cold-read ladder (cache row + persistence tail replay) |
|
||||
@@ -7,3 +7,4 @@
|
||||
| 包 | ctx 键 | 职责 |
|
||||
|---|---|---|
|
||||
| [`session-projection`](session-projection/README.md) | `sessionProjections` | 接口包(package):merge-extensible 的 `SessionProjectionMap` 类型表、`ProjectionDefinition` 单元契约,以及供载体同步读取的正向驱动注册表 |
|
||||
| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | 持久投影缓存:基于域数据形态的按会话单元 checkpoint 持久化、带 turn/end + detach 两个必写点的节流后写,以及冷读阶梯(缓存行 + 持久化尾部重放) |
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md
|
||||
README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e
|
||||
README.zh.md: ab4076df28cfe2b5a8b41d609967039dcacb7ef4
|
||||
@@ -0,0 +1,62 @@
|
||||
# @deepseek-ai/dsh-session-projection-cache
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
|
||||
|
||||
A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
|
||||
|
||||
- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
|
||||
- **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud.
|
||||
- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
|
||||
- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
|
||||
|
||||
## Write policy
|
||||
|
||||
Two mandatory points, throttled in between:
|
||||
|
||||
| Trigger | Nature |
|
||||
|---|---|
|
||||
| `turn/end` | Mandatory — the turn-final value is what cold reads want. |
|
||||
| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. |
|
||||
| `writeEveryEvents` committed events | Config throttle (count). |
|
||||
| `writeIntervalMs` since the first dirty event | Config throttle (interval). |
|
||||
|
||||
Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml.
|
||||
|
||||
## Listing read (`cachedSnapshot(meta)`)
|
||||
|
||||
The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no version-matching rows); the api-proxy list carrier turns that into an absent column.
|
||||
|
||||
## Cold read (`coldSnapshot(id, signal?)`)
|
||||
|
||||
The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`.
|
||||
|
||||
`write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
|
||||
|
||||
## Composition
|
||||
|
||||
```yaml
|
||||
- id: session-projection-cache
|
||||
name: '@deepseek-ai/dsh-session-projection-cache'
|
||||
config:
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
```
|
||||
|
||||
Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; the cache never assembles or sends provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
|
||||
- **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
|
||||
- **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates.
|
||||
@@ -0,0 +1,62 @@
|
||||
# @deepseek-ai/dsh-session-projection-cache
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
|
||||
|
||||
一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
|
||||
|
||||
- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。
|
||||
- **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
|
||||
- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。
|
||||
- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
|
||||
- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。
|
||||
|
||||
## 写策略
|
||||
|
||||
两个必写点,其间节流:
|
||||
|
||||
| 触发 | 性质 |
|
||||
|---|---|
|
||||
| `turn/end` | 必写——冷读要的正是轮次终值。 |
|
||||
| 会话销毁(detach) | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 |
|
||||
| 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 |
|
||||
| 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 |
|
||||
|
||||
两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。
|
||||
|
||||
## 列表读(`cachedSnapshot(meta)`)
|
||||
|
||||
零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值仓时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
|
||||
|
||||
## 冷读(`coldSnapshot(id, signal?)`)
|
||||
|
||||
读取阶梯,快乐路径零全量日志加载:缓存行 → `sessionProjections.restoreFloor`(锚在最低可用水位下一格)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。
|
||||
|
||||
`write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。
|
||||
|
||||
## 组合
|
||||
|
||||
```yaml
|
||||
- id: session-projection-cache
|
||||
name: '@deepseek-ai/dsh-session-projection-cache'
|
||||
config:
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
```
|
||||
|
||||
注入 `storageDomain`、`sessionProjections`、`sessionPersistence`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为缓存只持久化并恢复 host 侧的、由已入日志会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;缓存从不组装或发送提供方请求。
|
||||
|
||||
## 已知局限与延后工作
|
||||
|
||||
- **没有淘汰或保留面**——记录按会话累积;清理存储的检查点是带外维护,与会话持久化本身同一立场。
|
||||
- **间隔节流按会话粗粒度**——计时器在一次干净写入后的首个脏事件时武装;持续的低于阈值的涓流每个间隔写一次,不是滑动窗口。
|
||||
- **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-projection-cache",
|
||||
"description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-storage-domain": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Persisted projection cache (`ctx.sessionProjectionCache`): durable
|
||||
* checkpoints of every registered projection unit's state, one record per
|
||||
* session on the domain data form (`session_projcache` domain — the shipped
|
||||
* json backend lands it beside `workspace.json`). The cache is a fold
|
||||
* shortcut, never an authority: a row is possibly stale (its `seq`
|
||||
* says how stale) but never wrong, so every write path is fail-soft (a lost
|
||||
* write costs a longer tail replay on the next cold read) and a
|
||||
* `ver` mismatch discards the row instead of migrating it. Design
|
||||
* authority: the session-projection RFC
|
||||
* (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
||||
* @module @deepseek-ai/dsh-session-projection-cache
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Empty type import: applies the package's cordis Context merge
|
||||
// (`ctx.sessionPersistence`), which this service reads on the cold path.
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { projectionCacheDomainSpec } from './spec.ts'
|
||||
import type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
|
||||
|
||||
export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts'
|
||||
export type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionProjectionCache: SessionProjectionCache
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin config. Both throttle triggers are deployment choices with no
|
||||
* universally correct value, so the composition states them explicitly
|
||||
* (cordis.yml); the two mandatory write points (`turn/end` and session
|
||||
* disposal) are policy, not tunables, and always fire.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Committed events per session that force a durable checkpoint write between mandatory points. */
|
||||
writeEveryEvents: number
|
||||
/** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
|
||||
writeIntervalMs: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
writeEveryEvents: z.natural().min(1).required(),
|
||||
writeIntervalMs: z.natural().min(1).required(),
|
||||
})
|
||||
|
||||
/** Per-session write-behind bookkeeping (live sessions only; dropped at retire). */
|
||||
interface DirtyState {
|
||||
/** Committed events since the last durable write. */
|
||||
pending: number
|
||||
/** Interval trigger armed at the first dirty event after a clean write. */
|
||||
timer: ReturnType<typeof setTimeout> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted projection cache service. Opens the `session_projcache`
|
||||
* domain at init, checkpoints live sessions on a throttled write-behind
|
||||
* (count/interval triggers from {@link Config}) plus two mandatory points —
|
||||
* `turn/end` and session disposal (the live-to-cold moment) — and serves the
|
||||
* cold-read ladder: cached row, persistence `readFrom` tail, registry
|
||||
* `restore`, durable write-back. Every durable write is fail-soft: failures
|
||||
* log a warning and the cache self-heals on the next write or cold read.
|
||||
*/
|
||||
export class SessionProjectionCache extends Service {
|
||||
static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions']
|
||||
|
||||
static Config: z<Config> = Config
|
||||
|
||||
private table?: KvTable<SessionId, CheckpointRecord>
|
||||
private readonly dirty = new Map<Session, DirtyState>()
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'sessionProjectionCache')
|
||||
}
|
||||
|
||||
/** Open the domain and install the write-behind listeners. */
|
||||
protected async [Service.init](): Promise<void> {
|
||||
const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec)
|
||||
this.ctx.effect(() => () => domain.close(), 'sessionProjectionCache.domainClose')
|
||||
this.table = domain.table('sessions')
|
||||
this.installWritePath()
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored record for one session, accepted only when its bound log
|
||||
* identity matches `expected`. A session id names a slot, not a lifecycle:
|
||||
* a recreated id or a persistence store swapped under a surviving cache
|
||||
* must not let an old record seed state folded from an unrelated log.
|
||||
* Synchronous from the domain's in-memory state.
|
||||
* @param id - the session whose record is read.
|
||||
* @param expected - the log identity the caller holds (live or stored header).
|
||||
* @returns the identity-matching record, or `undefined` (absent or unrelated).
|
||||
*/
|
||||
private recordFor(id: SessionId, expected: CheckpointIdentity): CheckpointRecord | undefined {
|
||||
const record = this.requireTable().get(id)
|
||||
if (record === undefined) return undefined
|
||||
return identityMatches(record.identity, expected) ? record : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The zero-I/O listing read: whole values viewed straight from the stored
|
||||
* rows (version-matching keys only), each cut carried with its watermark
|
||||
* so a client value store can seed under its higher-seq-wins rule — as
|
||||
* stale as the last durable checkpoint but never wrong, and never from an
|
||||
* unrelated log (the caller's header is the identity witness). Fresher
|
||||
* paths (the history tail baseline, {@link coldSnapshot}) supersede these
|
||||
* values whenever a session is actually opened.
|
||||
* @param meta - the listed session's header (identity witness; no log read).
|
||||
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
|
||||
* `undefined` when no usable row exists for this lifecycle.
|
||||
*/
|
||||
cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined {
|
||||
const record = this.recordFor(meta.id, identityOf(meta))
|
||||
if (record === undefined) return undefined
|
||||
const values = this.ctx.sessionProjections.viewCheckpoint(record.rows)
|
||||
const keys = Object.keys(values)
|
||||
if (keys.length === 0) return undefined
|
||||
// The block carries ONE cut: the lowest served watermark is the seq every
|
||||
// value is at least current as of (under-claiming is safe under
|
||||
// higher-seq-wins; over-claiming would let a stale value outrank pushes).
|
||||
const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq))
|
||||
return { asOfSeq, values }
|
||||
}
|
||||
|
||||
/**
|
||||
* Durably checkpoint one live session NOW (both mandatory points call
|
||||
* this; tests and carriers may too). The registry cut is snapshotted at
|
||||
* this boundary (states are live references), then the whole record is
|
||||
* replaced. NOT fail-soft — callers on the fail-soft paths contain it.
|
||||
* @param session - the live session to checkpoint.
|
||||
* @returns resolution after durability and event emission.
|
||||
*/
|
||||
async write(session: Session): Promise<void> {
|
||||
const rows = this.ctx.sessionProjections.checkpoint(session)
|
||||
this.markClean(session)
|
||||
// Durability barrier: the checkpoint cut was taken above, so flushing
|
||||
// AFTER it guarantees every event inside the cut is durably logged
|
||||
// before the cache row lands — a crash can leave the cache behind the
|
||||
// log (longer tail replay) but never ahead of it (phantom values folded
|
||||
// from events no stored log contains). At detach the store entry is
|
||||
// already gone; persistence's own retirement drain covers that path and
|
||||
// any residual overreach is caught by the cold read's anchored floor.
|
||||
if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session)
|
||||
await this.put(session.id, identityOf(session.header), rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-read one persisted session's projections with zero full-log load:
|
||||
* cached rows + a persistence `readFrom` tail from the registry's restore
|
||||
* floor, refolded by the registry and written back (fail-soft) so the next
|
||||
* cold read starts closer. A cache row invalidated by a shrunk log
|
||||
* (crash-repair truncation) triggers one full re-read from seq 0 — the
|
||||
* ladder's slow rung, still no crash. Rejects when the session has no
|
||||
* persisted log (`not found` from the persistence seam).
|
||||
* @param id - the persisted session to read.
|
||||
* @param signal - optional cancellation for the persistence reads.
|
||||
* @returns the snapshot cut at the stored log end.
|
||||
*/
|
||||
async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
|
||||
const record = this.requireTable().get(id)
|
||||
const cached = record?.rows ?? {}
|
||||
const floor = this.ctx.sessionProjections.restoreFloor(cached)
|
||||
const persistence = this.ctx.sessionPersistence
|
||||
if (floor === undefined) {
|
||||
// No unit registered: nothing to fold, but the not-found contract must
|
||||
// hold in this topology too — the probe read rejects for an absent log
|
||||
// and dates the empty cut for a present one.
|
||||
const probe = await persistence.readFrom(id, 0, signal)
|
||||
return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} }
|
||||
}
|
||||
let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
const tail = await persistence.readFrom(id, floor, signal)
|
||||
// The tail's stored header is the identity witness: a record bound to a
|
||||
// different lifecycle (recreated id, swapped store) is discarded whole
|
||||
// before any of its rows can seed a fold.
|
||||
const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
|
||||
try {
|
||||
if (!related) throw new Error('unrelated log identity')
|
||||
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
|
||||
} catch {
|
||||
// The recoverable restore failures: an unrelated record, or a row
|
||||
// overreaching the stored log end (or predating the floor). Both imply
|
||||
// floor > 0 (baseSeq-0 restores never throw and an unrelated record
|
||||
// still carried a usable watermark), so the full log is a fresh read.
|
||||
const whole = await persistence.readFrom(id, 0, signal)
|
||||
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
|
||||
}
|
||||
await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
|
||||
return restored.snapshot
|
||||
}
|
||||
|
||||
// --- write-behind (throttle + mandatory points) ---
|
||||
|
||||
private installWritePath(): void {
|
||||
// Every committed event advances the dirty counter; turn/end is a
|
||||
// mandatory point (the durable value most reads want is the turn-final
|
||||
// one), count/interval throttle the in-turn stream.
|
||||
this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type === 'turn/end') {
|
||||
void this.flushSoft(session, 'turn/end')
|
||||
return
|
||||
}
|
||||
const state = this.dirty.get(session) ?? { pending: 0, timer: undefined }
|
||||
this.dirty.set(session, state)
|
||||
state.pending += 1
|
||||
if (state.pending >= this.config.writeEveryEvents) {
|
||||
void this.flushSoft(session, 'count threshold')
|
||||
return
|
||||
}
|
||||
state.timer ??= setTimeout(() => {
|
||||
void this.flushSoft(session, 'interval')
|
||||
}, this.config.writeIntervalMs)
|
||||
})
|
||||
|
||||
// Detach (the live-to-cold moment): the second mandatory point. After
|
||||
// this write the cold-read ladder serves the session from the cache.
|
||||
// flushSoft's synchronous prefix reads and resets the dirty state, so
|
||||
// dropping it (timer already cleared by markClean) right after is safe.
|
||||
this.ctx.on('session/disposed', (session: Session) => {
|
||||
void this.flushSoft(session, 'detach')
|
||||
this.markClean(session)
|
||||
this.dirty.delete(session)
|
||||
})
|
||||
|
||||
// Clear pending timers with the plugin (their sessions outlive the cache).
|
||||
this.ctx.effect(() => () => {
|
||||
for (const state of this.dirty.values()) {
|
||||
if (state.timer !== undefined) clearTimeout(state.timer)
|
||||
}
|
||||
this.dirty.clear()
|
||||
}, 'sessionProjectionCache.timers')
|
||||
}
|
||||
|
||||
/**
|
||||
* One fail-soft durable checkpoint. Every caller has work by construction:
|
||||
* the throttle triggers only fire dirty (markClean clears the timer with
|
||||
* the counter) and the two mandatory points write unconditionally.
|
||||
*/
|
||||
private async flushSoft(session: Session, trigger: string): Promise<void> {
|
||||
try {
|
||||
await this.write(session)
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset one session's dirty bookkeeping (its checkpoint is being written). */
|
||||
private markClean(session: Session): void {
|
||||
const state = this.dirty.get(session)
|
||||
if (state === undefined) return
|
||||
state.pending = 0
|
||||
if (state.timer !== undefined) {
|
||||
clearTimeout(state.timer)
|
||||
state.timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
|
||||
private async put(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise<void> {
|
||||
const detached = snapshotJsonValue(rows)
|
||||
if (detached === undefined) {
|
||||
throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)')
|
||||
}
|
||||
await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] })
|
||||
}
|
||||
|
||||
/** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
|
||||
private async putSoft(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint, what: string): Promise<void> {
|
||||
try {
|
||||
await this.put(id, identity, rows)
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private requireTable(): KvTable<SessionId, CheckpointRecord> {
|
||||
/* v8 ignore next -- Service.init assigns the table before the service becomes injectable */
|
||||
if (this.table === undefined) throw new Error('session projection cache is not initialized')
|
||||
return this.table
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a header onto the identity fields a record is bound to. */
|
||||
function identityOf(header: SessionHeader): CheckpointIdentity {
|
||||
return { createdAt: header.createdAt, ...header.cwd === undefined ? {} : { cwd: header.cwd } }
|
||||
}
|
||||
|
||||
/** Whether a stored record's bound identity names the caller's lifecycle. */
|
||||
function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean {
|
||||
return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd
|
||||
}
|
||||
|
||||
export default SessionProjectionCache
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-projection-cache`.
|
||||
* @module @deepseek-ai/dsh-session-projection-cache/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection-cache'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-projection-cache-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the cache's correctness relation (a stored row equals
|
||||
* the registry fold at its `seq` watermark) is only checkable by re-running the
|
||||
* fold over the persisted log — duplicating the implementation rather than
|
||||
* detecting drift — and its staleness is by design (fail-soft writes). The
|
||||
* durable boundary is already schema-validated by the storage-domain layer
|
||||
* on every reopen, and the read ladder's version/watermark guards are proven
|
||||
* by the package spec.
|
||||
*/
|
||||
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,71 @@
|
||||
/**
|
||||
* The session-projcache domain declaration: one `sessions` table keyed by
|
||||
* {@link SessionId}, each record the full projection checkpoint for one
|
||||
* session (`key → {ver, seq, val}` rows). The spec object
|
||||
* is the single source of the domain's identity, version, and record schema;
|
||||
* the storage-domain routing decides the medium (the shipped composition's
|
||||
* json backend lands it at `<root>/session_projcache.json`, beside
|
||||
* `workspace.json`).
|
||||
* @module @deepseek-ai/dsh-session-projection-cache/src/spec
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
|
||||
/**
|
||||
* One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
|
||||
* minus the two record keys). `val` is the unit's internal state — plain
|
||||
* JSON by the unit contract; `z.json()` enforces that at the durable
|
||||
* boundary. A row is never wrong, only possibly stale: `seq` says exactly
|
||||
* how stale, and a `ver` mismatch against the live unit's `stateVersion`
|
||||
* discards it at read time (never a migration).
|
||||
*/
|
||||
export const checkpointRow = z.object({
|
||||
ver: z.number().int().nonnegative(),
|
||||
seq: z.number().int().gte(-1),
|
||||
val: z.json(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The stored-log identity a record is bound to: the immutable header fields
|
||||
* that distinguish one session lifecycle from another under the same id. A
|
||||
* session id names a slot, not a lifecycle — a deleted-then-recreated id, or
|
||||
* a persistence root swapped under a surviving cache, would otherwise let an
|
||||
* old row pass every watermark check and seed state folded from an unrelated
|
||||
* log. Reads validate this against the live header (listing) or the stored
|
||||
* header (cold read) before accepting any row.
|
||||
*/
|
||||
export const checkpointIdentity = z.object({
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
cwd: z.string().optional(),
|
||||
})
|
||||
|
||||
/** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */
|
||||
export type CheckpointIdentity = z.infer<typeof checkpointIdentity>
|
||||
|
||||
/**
|
||||
* One session's stored record: the log identity it was folded from plus its
|
||||
* checkpoint rows keyed by projection key. The whole record is replaced on
|
||||
* every write (whole-value discipline — the registry checkpoint is always
|
||||
* the complete per-session cut).
|
||||
*/
|
||||
export const checkpointRecord = z.object({
|
||||
identity: checkpointIdentity,
|
||||
rows: z.record(z.string(), checkpointRow),
|
||||
})
|
||||
|
||||
/** One stored per-session checkpoint record, inferred from {@link checkpointRecord}. */
|
||||
export type CheckpointRecord = z.infer<typeof checkpointRecord>
|
||||
|
||||
/**
|
||||
* The session-projcache domain spec. Version bumps discard the whole medium
|
||||
* (cache semantics: a stale or unreadable cache costs a longer tail replay,
|
||||
* never a wrong value). v2 added the record's log-identity binding; v3
|
||||
* renamed the row fields to `ver`/`seq`/`val`.
|
||||
*/
|
||||
export const projectionCacheDomainSpec = defineDomain({
|
||||
name: 'session_projcache',
|
||||
version: 3,
|
||||
tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
|
||||
})
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
|
||||
* count/interval throttling between them, fail-soft durability (a failed
|
||||
* write logs and stays stale, never throws into the event path), and the
|
||||
* cold-read ladder (cached row + readFrom tail + registry restore +
|
||||
* write-back; version bump and shrunk-log rows degrade to a full re-read).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import SessionProjectionCache from '../src/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'cache-test/marks': { marks: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
'cache-test/mark': { marks: string[] }
|
||||
}
|
||||
|
||||
interface OutOfBandSessionEventMap {
|
||||
'cache-test/mark': true
|
||||
}
|
||||
}
|
||||
|
||||
type MarksState = { marks: string[] } | null
|
||||
const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', MarksState> => ({
|
||||
key: 'cache-test/marks',
|
||||
schema: z.object({ marks: z.array(z.string()) }),
|
||||
init: () => null,
|
||||
apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
|
||||
view: state => state ?? { marks: [] },
|
||||
stateVersion,
|
||||
})
|
||||
|
||||
/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
|
||||
function fakePersistence(logs: Map<string, SessionEvent[]>) {
|
||||
const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
|
||||
const events = logs.get(String(id))
|
||||
if (events === undefined) throw new Error(`session "${id}" not found`)
|
||||
return {
|
||||
meta: { version: 0, id, createdAt: 0 },
|
||||
events: events.filter(event => event.seq >= fromSeq),
|
||||
}
|
||||
})
|
||||
return { readFrom }
|
||||
}
|
||||
|
||||
/** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */
|
||||
const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
|
||||
({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
|
||||
|
||||
interface HarnessOptions {
|
||||
pool?: MemoryMediaPool
|
||||
config?: { writeEveryEvents: number; writeIntervalMs: number }
|
||||
stateVersion?: number
|
||||
logs?: Map<string, SessionEvent[]>
|
||||
}
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
async function harness(options: HarnessOptions = {}) {
|
||||
const pool = options.pool ?? new MemoryMediaPool()
|
||||
const logs = options.logs ?? new Map<string, SessionEvent[]>()
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(marksUnit(options.stateVersion))
|
||||
const persistence = fakePersistence(logs)
|
||||
ctx.provide('sessionPersistence', persistence as never)
|
||||
const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
|
||||
}
|
||||
|
||||
const mark = (session: Session, marks: string[]): SessionEvent =>
|
||||
session.append('cache-test/mark', { marks })
|
||||
|
||||
const endTurn = (session: Session): SessionEvent =>
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
/** The stored medium record for one session id (undefined = never written). */
|
||||
function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
|
||||
return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as
|
||||
{
|
||||
identity: { createdAt: number; cwd?: string }
|
||||
rows: Record<string, { ver: number; seq: number; val: unknown }>
|
||||
} | undefined
|
||||
}
|
||||
|
||||
/** The stored medium rows for one session id (undefined = never written). */
|
||||
function storedRows(pool: MemoryMediaPool, id: Session['id']) {
|
||||
return storedRecord(pool, id)?.rows
|
||||
}
|
||||
|
||||
/** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
describe('SessionProjectionCache write policy', () => {
|
||||
it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
|
||||
const { ctx, pool } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('turn-end'))
|
||||
mark(session, ['a'])
|
||||
expect(storedRows(pool, session.id)).toBeUndefined() // throttled: no write yet
|
||||
const end = endTurn(session)
|
||||
await settle()
|
||||
const rows = storedRows(pool, session.id)
|
||||
expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
|
||||
})
|
||||
|
||||
it('writes at session disposal (detach, the live-to-cold moment)', async () => {
|
||||
const { ctx, pool } = await harness()
|
||||
// Sessions dispose with their owning fiber: create in a child plugin.
|
||||
let session: Session | undefined
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create(SessionId('detach'))
|
||||
}, { inject: ['sessions'] }))
|
||||
if (session === undefined) throw new Error('session was not created')
|
||||
mark(session, ['live'])
|
||||
await owner.dispose()
|
||||
await settle()
|
||||
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
|
||||
})
|
||||
|
||||
it('flushes when the in-turn event count reaches the configured threshold', async () => {
|
||||
const { ctx, pool } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
|
||||
const session = ctx.sessions.create(SessionId('count'))
|
||||
mark(session, ['1'])
|
||||
mark(session, ['2'])
|
||||
await settle()
|
||||
expect(storedRows(pool, session.id)).toBeUndefined()
|
||||
mark(session, ['3'])
|
||||
await settle()
|
||||
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
|
||||
})
|
||||
|
||||
it('flushes on the configured interval when the count threshold is not reached', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { ctx, pool } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 250 } })
|
||||
const session = ctx.sessions.create(SessionId('interval'))
|
||||
mark(session, ['slow'])
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(storedRows(pool, session.id)).toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
|
||||
})
|
||||
|
||||
it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
|
||||
const { ctx, pool } = await harness()
|
||||
// Never dirtied: no events — write() still lands the init-derived cut.
|
||||
const clean = ctx.sessions.create(SessionId('clean-write'))
|
||||
await ctx.sessionProjectionCache.write(clean)
|
||||
expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
|
||||
// A unit whose state violates the plain-JSON contract fails the write loud.
|
||||
ctx.sessionProjections.register({
|
||||
key: 'cache-test/marks2' as never,
|
||||
schema: { parse: (value: unknown) => value } as never,
|
||||
init: () => new Map<string, string>(),
|
||||
apply: (state: unknown) => state,
|
||||
view: () => null as never,
|
||||
stateVersion: 1,
|
||||
})
|
||||
await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { ctx, pool, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
|
||||
const armed = ctx.sessions.create(SessionId('armed'))
|
||||
const cleaned = ctx.sessions.create(SessionId('cleaned'))
|
||||
mark(armed, ['pending']) // timer armed, no write yet
|
||||
mark(cleaned, ['done'])
|
||||
endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await fiber.dispose()
|
||||
// The armed timer died with the plugin: advancing time writes nothing.
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(storedRows(pool, armed.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
|
||||
const { ctx, pool } = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = ctx.sessions.create(SessionId('fail-soft'))
|
||||
mark(session, ['x'])
|
||||
pool.failNextWrites = 1
|
||||
endTurn(session)
|
||||
await settle()
|
||||
expect(storedRows(pool, session.id)).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
|
||||
// Self-heal: the next mandatory point writes the current cut.
|
||||
mark(session, ['y'])
|
||||
endTurn(session)
|
||||
await settle()
|
||||
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionProjectionCache cold read', () => {
|
||||
const storedLog = (marks: string[][]): SessionEvent[] => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
]
|
||||
for (const m of marks) {
|
||||
events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } })
|
||||
}
|
||||
events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
return events
|
||||
}
|
||||
|
||||
/** Pre-seed the medium with one stored checkpoint record (before the domain opens). */
|
||||
function seedRow(
|
||||
pool: MemoryMediaPool,
|
||||
id: string,
|
||||
row: { ver: number; seq: number; val: unknown },
|
||||
identity: { createdAt: number; cwd?: string } = { createdAt: 0 },
|
||||
): void {
|
||||
pool.versions.set('session_projcache', 3)
|
||||
pool.media.set('session_projcache', {
|
||||
tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]),
|
||||
global: null,
|
||||
})
|
||||
}
|
||||
|
||||
it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
|
||||
// A warm-era checkpoint at watermark 1 (only ['a'] folded).
|
||||
seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } })
|
||||
const { cache, persistence, pool: samePool } = await harness({ pool, logs })
|
||||
const id = SessionId('cold')
|
||||
const snapshot = await cache.coldSnapshot(id)
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] })
|
||||
expect(snapshot.asOfSeq).toBe(3)
|
||||
// The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0.
|
||||
expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
|
||||
// Write-back: the stored row advanced to the served cut.
|
||||
expect(storedRows(samePool, id)?.['cache-test/marks'])
|
||||
.toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
|
||||
})
|
||||
|
||||
it('discards a version-mismatched row and refolds the full log', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['bumped', storedLog([['a']])]])
|
||||
seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } })
|
||||
const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 })
|
||||
const snapshot = await cache.coldSnapshot(SessionId('bumped'))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
|
||||
// Mismatch pulls the floor to 0: one full read, no second pass needed.
|
||||
expect(persistence.readFrom).toHaveBeenCalledTimes(1)
|
||||
expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined)
|
||||
})
|
||||
|
||||
it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
|
||||
seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } })
|
||||
const { cache, persistence } = await harness({ pool, logs })
|
||||
const snapshot = await cache.coldSnapshot(SessionId('shrunk'))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
|
||||
expect(snapshot.asOfSeq).toBe(2)
|
||||
// Anchored tail read (floor 9) came back empty -> full re-read from 0.
|
||||
expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined)
|
||||
expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
|
||||
})
|
||||
|
||||
it('write-back failure is contained: the snapshot is still served', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['soft', storedLog([['a']])]])
|
||||
const { ctx, cache } = await harness({ pool, logs })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
pool.failNextWrites = 1
|
||||
const snapshot = await cache.coldSnapshot(SessionId('soft'))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed'))
|
||||
})
|
||||
|
||||
it('rejects for a session with no persisted log', async () => {
|
||||
const { cache } = await harness()
|
||||
await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0
|
||||
// A checkpoint from a PRIOR lifecycle of the same id (different createdAt):
|
||||
// its rows pass every watermark check, but the identity does not match.
|
||||
seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 })
|
||||
const { cache, pool: samePool } = await harness({ pool, logs })
|
||||
const snapshot = await cache.coldSnapshot(SessionId('reborn'))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
|
||||
// The write-back rebinds the record to the actual log's identity.
|
||||
expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 })
|
||||
})
|
||||
|
||||
it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } })
|
||||
const { cache } = await harness({ pool })
|
||||
expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
|
||||
const { cache } = await harness({ pool })
|
||||
const id = SessionId('homed')
|
||||
expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
|
||||
expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
|
||||
expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dates an empty stored log at -1 in the zero-units topology', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['empty', [] as SessionEvent[]]])
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.provide('sessionPersistence', fakePersistence(logs) as never)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty')))
|
||||
.resolves.toEqual({ asOfSeq: -1, values: {} })
|
||||
})
|
||||
|
||||
it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } })
|
||||
const { cache } = await harness({ pool })
|
||||
const id = SessionId('listed')
|
||||
// Matching header: values plus the watermark the client seeds under.
|
||||
expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
|
||||
// A recreated id (different createdAt): the record is unrelated — no block.
|
||||
expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
|
||||
// Unknown id: no block.
|
||||
expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
|
||||
// Same composition minus any registered unit: restoreFloor is undefined,
|
||||
// yet coldSnapshot must still reject for an absent log (probe read) and
|
||||
// serve an empty cut at the stored end for a present one.
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.provide('sessionPersistence', fakePersistence(logs) as never)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
|
||||
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare')))
|
||||
.resolves.toEqual({ asOfSeq: 2, values: {} })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../storage/storage"
|
||||
},
|
||||
{
|
||||
"path": "../../storage/storage-domain"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-projection/session-projection/README.md
|
||||
README.md: 2e026aab55933c96ba961481f9597bc18cbbe910
|
||||
README.zh.md: a3e0b0f46466d19321b0950dc41d06473a54a1ce
|
||||
README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e
|
||||
README.zh.md: 385862868df495a5c857d91c32f6503c3ef72025
|
||||
@@ -23,7 +23,7 @@ Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRI
|
||||
- **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
|
||||
- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
|
||||
- **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly.
|
||||
- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache (a later phase) stores `(sessionId, key, stateVersion, observedSeq, stateJson)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
|
||||
- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
|
||||
- **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them.
|
||||
- **Optional seam.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent.
|
||||
|
||||
@@ -43,5 +43,5 @@ None; projections never assemble or send provider requests.
|
||||
|
||||
- **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
|
||||
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
|
||||
- **The persisted projection cache is a later phase** — cells live in memory only; a restart rebuilds by folding the in-memory log on first touch. The `stateVersion` field is the forward-declared invalidation anchor for that phase.
|
||||
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
|
||||
- **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.
|
||||
@@ -23,7 +23,7 @@
|
||||
- **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
|
||||
- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
|
||||
- **单元的同步纪律。** `init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。
|
||||
- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache,后续阶段)存储 `(sessionId, key, stateVersion, observedSeq, stateJson)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
|
||||
- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
|
||||
- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
|
||||
- **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
|
||||
- **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
|
||||
- **正向驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。
|
||||
- **持久投影缓存属于后续阶段**——cell 目前只活在内存里;重启后首次触达时靠折叠内存日志重建。`stateVersion` 字段是为该阶段预先声明的失效锚点。
|
||||
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
|
||||
- **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。
|
||||
@@ -66,9 +66,9 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
|
||||
view(state: S): SessionProjectionMap[K]
|
||||
/**
|
||||
* Persisted-cache invalidation anchor: bump whenever the state shape or the
|
||||
* fold semantics change, so persisted `(sessionId, key, stateVersion,
|
||||
* observedSeq, state)` rows from an older unit are discarded instead of
|
||||
* being forward-applied into garbage. Non-negative integer.
|
||||
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
|
||||
* rows from an older unit are discarded instead of being forward-applied
|
||||
* into garbage. Non-negative integer.
|
||||
*/
|
||||
stateVersion: number
|
||||
}
|
||||
@@ -97,6 +97,26 @@ export interface ProjectionSnapshot {
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/**
|
||||
* One unit's checkpoint: its internal state (plain JSON by the unit
|
||||
* contract), the seq of the last event folded into it, and the unit
|
||||
* `stateVersion` that produced it — the persisted projection-cache row
|
||||
* `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is
|
||||
* never authoritative, only a fold shortcut: `restore` discards it on a
|
||||
* version mismatch or when it claims events past the stored log end.
|
||||
*/
|
||||
export interface ProjectionCheckpointRow {
|
||||
/** The registering unit's `stateVersion` at fold time. */
|
||||
ver: number
|
||||
/** Seq of the last event folded into `val`; -1 for the empty log. */
|
||||
seq: number
|
||||
/** The unit's internal state — plain JSON per the unit contract. */
|
||||
val: unknown
|
||||
}
|
||||
|
||||
/** Checkpoint rows keyed by projection key (one session's persisted cache value). */
|
||||
export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>
|
||||
|
||||
/** Type-erased unit view the drive machinery works with (the register seam already proved the typed contract). */
|
||||
interface ErasedDefinition {
|
||||
key: string
|
||||
@@ -206,6 +226,136 @@ export class SessionProjectionRegistry extends Service {
|
||||
return { asOfSeq: session.seq - 1, values: values }
|
||||
}
|
||||
|
||||
/**
|
||||
* State-level checkpoint of every registered unit for one session, read
|
||||
* from the watermark cache (missing cells fold lazily over the in-memory
|
||||
* log). This is the write side of the persisted projection cache: the
|
||||
* returned rows are the `(key → {ver, seq, val})` part of the durable
|
||||
* `(sessionId, key, ver, seq, val)`
|
||||
* rows. Every `val` is a DETACHED structured clone — never the live
|
||||
* cell reference: the watermark cache is this registry's authoritative
|
||||
* mutable state, and a caller reaching the live reference could corrupt
|
||||
* every subsequent snapshot and frame through it (plain JSON by the unit
|
||||
* contract, so the clone is total).
|
||||
* @param session - the session whose unit states are checkpointed.
|
||||
* @returns one row per registered key; empty when no unit is registered.
|
||||
*/
|
||||
checkpoint(session: Session): ProjectionCheckpoint {
|
||||
const rows: ProjectionCheckpoint = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const cell = this.cellFor(registration, session)
|
||||
rows[registration.def.key] = {
|
||||
ver: registration.def.stateVersion,
|
||||
seq: cell.observedSeq,
|
||||
val: structuredClone(cell.state),
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored seq a {@link restore} tail read over `checkpoint` must start
|
||||
* at: one event BELOW the lowest usable watermark (a row is usable when
|
||||
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
|
||||
* pulls the floor to `0` — that key must refold the full log). The
|
||||
* one-below anchor is load-bearing: the tail then proves how far the
|
||||
* stored log still extends, so {@link restore} can detect a log that
|
||||
* shrank below a row's watermark (crash-repair truncation) instead of
|
||||
* serving the stale row as current — an empty tail read from the anchor
|
||||
* yields an end below every watermark and the restore rejects for a full
|
||||
* re-read.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns the seq to hand the persistence `readFrom`, or `undefined`
|
||||
* when no unit is registered (no read needed — {@link restore} would
|
||||
* serve empty values regardless).
|
||||
*/
|
||||
restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined {
|
||||
let floor: number | undefined
|
||||
for (const registration of this.registrations.values()) {
|
||||
const row = checkpoint[registration.def.key]
|
||||
const need = row !== undefined && row.ver === registration.def.stateVersion
|
||||
? Math.max(row.seq + 1, 0)
|
||||
: 0
|
||||
floor = floor === undefined ? need : Math.min(floor, need)
|
||||
}
|
||||
return floor === undefined ? undefined : Math.max(floor - 1, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* View a checkpoint's rows without any log read: for every registered
|
||||
* unit whose row's `ver` matches, serve the schema-validated
|
||||
* `view` of the stored state; mismatched or absent rows leave their key
|
||||
* absent (a cold or listing consumer treats it as not-yet-available and a
|
||||
* fuller read path refolds it). The zero-I/O rung of the read ladder —
|
||||
* values are as stale as their rows, never wrong.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns whole values per key with a usable row; empty when none.
|
||||
*/
|
||||
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap> {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const def = registration.def
|
||||
const row = checkpoint[def.key]
|
||||
if (row === undefined || row.ver !== def.stateVersion) continue
|
||||
values[def.key] = def.schema.parse(def.view(row.val))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold read: fold every registered unit over a stored log suffix, seeding
|
||||
* each from its checkpoint row when usable — the one read recipe (cached
|
||||
* state + forward tail replay + `view`) applied without a live `Session`.
|
||||
* Call with the events returned by a persistence
|
||||
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
|
||||
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
|
||||
* so a shrunk log is detected here. A row is usable iff its
|
||||
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
|
||||
* (`seq >= baseSeq - 1`), and it does not claim events past the
|
||||
* supplied end (`seq <= endSeq`); an unusable row is discarded
|
||||
* and its key refolds from `init` — which is only sound over the full
|
||||
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
|
||||
* from seq 0, e.g. after a crash-repair truncation shrank the log below
|
||||
* a row's watermark).
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
||||
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
*/
|
||||
restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number):
|
||||
{ snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
|
||||
const endSeq = events.at(-1)?.seq ?? baseSeq - 1
|
||||
const values: Record<string, unknown> = {}
|
||||
const refreshed: ProjectionCheckpoint = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const def = registration.def
|
||||
const row = checkpoint[def.key]
|
||||
const usable = row !== undefined
|
||||
&& row.ver === def.stateVersion
|
||||
&& row.seq >= baseSeq - 1
|
||||
&& row.seq <= endSeq
|
||||
if (!usable && baseSeq > 0) {
|
||||
throw new Error(
|
||||
`session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: `
|
||||
+ 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0',
|
||||
)
|
||||
}
|
||||
let state = usable ? row.val : def.init()
|
||||
const from = usable ? row.seq : baseSeq - 1
|
||||
for (const event of events) {
|
||||
if (event.seq > from) state = def.apply(state, event)
|
||||
}
|
||||
values[def.key] = def.schema.parse(def.view(state))
|
||||
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
|
||||
}
|
||||
return {
|
||||
snapshot: { asOfSeq: endSeq, values: values },
|
||||
checkpoint: refreshed,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
||||
private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell {
|
||||
let state = def.init()
|
||||
|
||||
@@ -169,6 +169,154 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
|
||||
})
|
||||
|
||||
it('checkpoints every registered unit with its stateVersion and per-cell watermark', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 })
|
||||
const markEvent = mark(session, ['a'])
|
||||
const rows = ctx.sessionProjections.checkpoint(session)
|
||||
expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } })
|
||||
expect(rows['test/count']).toEqual({ ver: 7, seq: markEvent.seq, val: 1 })
|
||||
// Empty log: init-derived state at watermark -1.
|
||||
const fresh = ctx.sessions.create()
|
||||
expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ ver: 1, seq: -1, val: null })
|
||||
})
|
||||
|
||||
it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
mark(session, ['a'])
|
||||
const rows = ctx.sessionProjections.checkpoint(session)
|
||||
// Hostile (or merely careless) consumer mutates the handed-out state.
|
||||
;(rows['test/marks']?.val as { marks: string[] }).marks.push('INJECTED')
|
||||
// The registry's authoritative cell is untouched: snapshot and a fresh
|
||||
// checkpoint both still serve the committed value.
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] })
|
||||
expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.val).toEqual({ marks: ['a'] })
|
||||
})
|
||||
|
||||
it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => {
|
||||
const { ctx } = await harness()
|
||||
expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
expect(ctx.sessionProjections.restoreFloor({})).toBe(0)
|
||||
// Lowest usable watermark is count's 5 → the anchored tail starts AT 5
|
||||
// (one below the first needed seq 6), so the read proves seq 5 still exists.
|
||||
expect(ctx.sessionProjections.restoreFloor({
|
||||
'test/marks': { ver: 1, seq: 10, val: { marks: [] } },
|
||||
'test/count': { ver: 1, seq: 5, val: 6 },
|
||||
})).toBe(5)
|
||||
// A version-mismatched row forces that key back to a full refold.
|
||||
expect(ctx.sessionProjections.restoreFloor({
|
||||
'test/marks': { ver: 2, seq: 10, val: { marks: [] } },
|
||||
'test/count': { ver: 1, seq: 5, val: 6 },
|
||||
})).toBe(0)
|
||||
// A fresh (-1) row still needs the whole tail from 0.
|
||||
expect(ctx.sessionProjections.restoreFloor({
|
||||
'test/marks': { ver: 1, seq: -1, val: null },
|
||||
'test/count': { ver: 1, seq: -1, val: 0 },
|
||||
})).toBe(0)
|
||||
})
|
||||
|
||||
it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const tail: SessionEvent[] = [
|
||||
{ type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
// marks row usable (watermark 2, tail starts at 3); count row mismatched — but
|
||||
// a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read.
|
||||
expect(() => ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, tail, 3)).toThrow(/re-read from seq 0/)
|
||||
// The full-log re-read (baseSeq 0) refolds the mismatched key from init.
|
||||
const full: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } },
|
||||
{ type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } },
|
||||
...tail,
|
||||
]
|
||||
const { snapshot, checkpoint } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, full, 0)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
|
||||
expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events
|
||||
// The refreshed rows sit at the served cut, ready for a durable write-back.
|
||||
expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } })
|
||||
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
|
||||
})
|
||||
|
||||
it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const rows = {
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
|
||||
'test/count': { ver: 1, seq: 2, val: 3 },
|
||||
}
|
||||
const tail: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
// marks already covers the tail (watermark 4): nothing re-applied.
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] })
|
||||
// count folds exactly seqs 3 and 4 on top of its checkpoint.
|
||||
expect(snapshot.values['test/count']).toBe(5)
|
||||
|
||||
// Empty tail (checkpoint is current): the cut sits at baseSeq - 1.
|
||||
const { snapshot: current } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
|
||||
'test/count': { ver: 1, seq: 4, val: 5 },
|
||||
}, [], 5)
|
||||
expect(current.asOfSeq).toBe(4)
|
||||
expect(current.values['test/count']).toBe(5)
|
||||
})
|
||||
|
||||
it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const values = ctx.sessionProjections.viewCheckpoint({
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } },
|
||||
'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent
|
||||
})
|
||||
expect(values['test/marks']).toEqual({ marks: ['stored'] })
|
||||
expect('test/count' in values).toBe(false)
|
||||
expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
|
||||
})
|
||||
|
||||
it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } }
|
||||
// The anchored floor sits ON the watermark, so the tail read must return
|
||||
// at least seq 9 from an intact log…
|
||||
const floor = ctx.sessionProjections.restoreFloor(rows)
|
||||
expect(floor).toBe(9)
|
||||
// …an intact log serves the anchor event and the checkpoint stands as-is.
|
||||
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
|
||||
expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
|
||||
// …while a log crash-repaired down to fewer events returns an empty tail:
|
||||
// the row overreaches the proven end and a tail read cannot fix this key.
|
||||
expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/)
|
||||
// The full re-read discards the overreaching row and refolds from init.
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
|
||||
expect(snapshot.asOfSeq).toBe(1)
|
||||
expect(snapshot.values['test/count']).toBe(2)
|
||||
})
|
||||
|
||||
it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register({
|
||||
|
||||
@@ -149,6 +149,11 @@ class TestPersistence extends SessionPersistence {
|
||||
return structuredClone(entry)
|
||||
}
|
||||
|
||||
async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const whole = await this.inspect(id, signal)
|
||||
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
|
||||
}
|
||||
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
TestPersistence.listStarted?.()
|
||||
await TestPersistence.listGate
|
||||
|
||||
@@ -96,6 +96,11 @@ class TestPersistence extends SessionPersistence {
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
|
||||
async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const whole = await this.inspect(id, signal)
|
||||
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
|
||||
}
|
||||
|
||||
list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
||||
TestPersistence.listCalls += 1
|
||||
TestPersistence.listSignals.push(signal)
|
||||
|
||||
@@ -76,6 +76,11 @@ class TracePersistence extends SessionPersistence {
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
}
|
||||
|
||||
async readFrom(id: SessionIdType, fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const whole = await this.inspect(id)
|
||||
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
TracePersistence.listCalls += 1
|
||||
if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure)
|
||||
|
||||
Generated
+37
@@ -242,6 +242,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/session-projection/session-projection
|
||||
'@deepseek-ai/dsh-session-projection-cache':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/session-projection/session-projection-cache
|
||||
'@deepseek-ai/dsh-session-title':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/session-title/session-title
|
||||
@@ -2685,6 +2688,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-projection/session-projection
|
||||
'@deepseek-ai/dsh-session-projection-cache':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-projection/session-projection-cache
|
||||
'@deepseek-ai/dsh-skill':
|
||||
specifier: workspace:^
|
||||
version: link:../../skill/skill
|
||||
@@ -3463,6 +3469,37 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/session-projection/session-projection-cache:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../session-projection
|
||||
'@deepseek-ai/dsh-storage':
|
||||
specifier: workspace:^
|
||||
version: link:../../storage/storage
|
||||
'@deepseek-ai/dsh-storage-domain':
|
||||
specifier: workspace:^
|
||||
version: link:../../storage/storage-domain
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/session-query/session-query:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
|
||||
@@ -209,6 +209,7 @@ const FOUNDATION_TYPE_NAMES = new Set([
|
||||
'AsyncIterable',
|
||||
'Context',
|
||||
'Error',
|
||||
'Partial',
|
||||
'Pick',
|
||||
'Promise',
|
||||
'Readonly',
|
||||
@@ -237,6 +238,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts',
|
||||
ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts',
|
||||
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
|
||||
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
|
||||
|
||||
@@ -244,6 +244,14 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-todo', 'session-title', 'host-apiproxy'],
|
||||
note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.',
|
||||
},
|
||||
{
|
||||
key: 'sessionProjectionCache',
|
||||
pkg: 'session-projection-cache',
|
||||
title: 'Persisted projection cache',
|
||||
mode: 'core',
|
||||
consumers: ['host-apiproxy'],
|
||||
note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.',
|
||||
},
|
||||
{
|
||||
key: 'tui',
|
||||
pkg: 'tui',
|
||||
|
||||
@@ -89,6 +89,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
|
||||
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
|
||||
'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
{ "path": "./packages/session-persistence/session-persistence-jsonl" },
|
||||
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
|
||||
{ "path": "./packages/session-projection/session-projection" },
|
||||
{ "path": "./packages/session-projection/session-projection-cache" },
|
||||
{ "path": "./packages/session-query/session-query" },
|
||||
{ "path": "./packages/session-query/session-query-sqlite" },
|
||||
{ "path": "./packages/session-query/tool-session-query" },
|
||||
|
||||
Reference in New Issue
Block a user