fix(session): migrate historical subagent catalogs to V4

This commit is contained in:
Dudu-0223
2026-09-16 22:00:38 +08:00
committed by Tianyi Cui
parent 3bde1a56e3
commit 1d1ae1a5af
64 changed files with 1668 additions and 161 deletions
@@ -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/implemented/architecture/2026-08-31-released-session-format-migrations.md
2026-08-31-released-session-format-migrations.md: 8d7b8cbc06b77fbe3587593df65f454cf7752518
2026-08-31-released-session-format-migrations.zh.md: 0443740ff1b724cd4f27dd636cd3dff381824f5a
2026-08-31-released-session-format-migrations.md: 379b4b735e1aaacb664faac57831b842de82c743
2026-08-31-released-session-format-migrations.zh.md: 0a2790f81987deb145845295064b1ef5a3bbcba7
@@ -67,7 +67,7 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
### Adjacent version ownership
The [V3-to-V4 specification](../../../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) owns an identity body conversion: it advances the header version while preserving admitted events and inherited cuts. It reuses the frozen V3 codec from V2→V3. Generation-aware delivery validation prevents a historical acknowledgement from acquiring a current watermark meaning solely through the header change; earlier edges retain their own source-admission policies.
The [V3-to-V4 specification](../../../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) specifies parent catalog completion: it advances the header version, preserves admitted events and inherited cuts, and appends missing own catalog entries from child evidence. It reuses the frozen V3 codec from V2→V3. Generation-aware delivery validation prevents a historical acknowledgement from acquiring a current watermark meaning solely through the header change; earlier edges retain their own source-admission policies.
The [V2-to-V3 delivery guards](../../../../packages/session/session-format-v2-to-v3/README.md#delivery-guards) prevent a marker ignored in the source generation from becoming an active upload watermark merely because the header changes. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
@@ -99,7 +99,7 @@ Current encoding is record based. The provider serializes about 1 MiB of plainte
The shipped `lib/worker.cjs` bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count.
The [browser preview packer](../../../../packages/experimental/webworker-packer/README.md) prepares current successors for bundled Session data in Node. The browser host does not implement `node:worker_threads`, so this preparation preserves both immutable fixture generations and runtime JSONL’s Worker verification requirement.
The [browser preview packer](../../../../packages/experimental/webworker-packer/README.md) prepares current successors for bundled Session data in Node, using same-root direct-child descriptors as parent catalog evidence. The browser host does not implement `node:worker_threads`, so this preparation preserves both immutable fixture generations and runtime JSONL’s Worker verification requirement.
Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back.
@@ -127,6 +127,44 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
| Verification repeats on the main thread | At most two complete-generation Workers | Keeps verification CPU off the main thread |
| Production and fixture migration use different APIs | Catalog `createRestore()` with explicit policies | One decoder/chain implementation |
### Parent catalog prerequisites
V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and requiring complete own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available discovery fields must agree with the parent; absent entries require a supported descriptor. Missing evidence refuses migration rather than reporting parent corruption. JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only `stat`/`list` and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.
The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.
<a id="catalog-scan-measurements"></a>
### Catalog scan measurements
The package-local [diagnostic](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts) measures one raw V3 parent, four direct children with 1,000 events each, and 0/100/1,000 unrelated header-only V3 Sessions. Each sample creates its own temporary corpus and Cordis context in a fresh process, runs stat → cold read → memo read → write publication → current warm read, and disposes the context and files. It uses built workspace exports under plain Node; fixture creation is outside the measured intervals. These are filesystem-cache-warm observations after fixture creation, not disk-cold latency or a performance gate.
On macOS arm64 with Node v26.0.0, all three samples per size are below in milliseconds. Read-only opens do not publish: membership scanning remains proportional to corpus size even when decoding is memoized. The 1,000-unrelated medians are 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These measurements document the cost; they establish no speedup claim or cross-host threshold.
| Unrelated Sessions | Historical stat | Cold read | Memo read | Publish | Current warm read |
|---:|---:|---:|---:|---:|---:|
| 0 | 5.17 | 19.00 | 1.36 | 42.88 | 0.47 |
| 0 | 3.30 | 14.87 | 1.45 | 35.75 | 0.41 |
| 0 | 3.18 | 16.71 | 1.50 | 34.51 | 0.47 |
| 100 | 29.44 | 77.48 | 17.01 | 60.66 | 0.43 |
| 100 | 12.42 | 42.71 | 13.69 | 60.19 | 0.43 |
| 100 | 11.19 | 41.06 | 12.61 | 57.18 | 0.39 |
| 1000 | 84.14 | 237.97 | 109.86 | 284.96 | 0.48 |
| 1000 | 99.41 | 259.88 | 123.50 | 268.46 | 0.41 |
| 1000 | 75.40 | 236.64 | 110.98 | 247.69 | 0.39 |
Run from the repository root after building the runtime:
```sh
pnpm run build:lib:host
cd packages/session/session-persistence-jsonl
node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
node .artifacts/perf/catalog-migration.perf.mjs
```
## Verification
The migration specification requires evidence for transformations, preservation, and refusal separately. Direct-edge and native V3 tests cannot establish seeded multi-hop publication: preceding assistant-stream folding changes source coordinates before V3 inserts system events. Tests through the real catalog and JSONL provider therefore need raw and compressed V0/V1 inputs, mapped references and inherited cuts, publish/reopen equivalence, unchanged predecessor bytes, and no intermediate generations. Coverage percentages alone cannot prove those cross-stage relationships; combined assertions must compare the resulting history and refusal effects.
@@ -67,7 +67,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
### 相邻版本所有权
[V3 到 V4 规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)负责 body 的恒等转换:它推进 header 版本,同时保留已接纳的事件与继承切点。它复用 V2→V3 中冻结的 V3 codec。按 generation 校验 delivery 可防止历史确认仅因 header 变化而获得当前水位含义;更早的 migration edge 保留各自的来源准入策略。
[V3 到 V4 规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)规定父目录补全:它推进 header 版本,保留已接纳的事件与继承切点,并根据子 Session 证据追加缺失的自身目录记录。它复用 V2→V3 中冻结的 V3 codec。按 generation 校验 delivery 可防止历史确认仅因 header 变化而获得当前水位含义;更早的 migration edge 保留各自的来源准入策略。
[V2 到 V3 投递保护](../../../../packages/session/session-format-v2-to-v3/README.zh.md#delivery-guards)防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
@@ -99,7 +99,7 @@ Current encode 以单条 record 为单位。Provider 在主线程每个 slice
发布的 `lib/worker.cjs` 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。
[浏览器 preview 打包器](../../../../packages/experimental/webworker-packer/README.zh.md)在 Node 中为随包 Session 数据准备当前代际后继。浏览器 host 未实现 `node:worker_threads`,因此打包阶段的准备同时保留不可变 fixture 代际与运行时 JSONL 的 Worker 校验要求。
[浏览器 preview 打包器](../../../../packages/experimental/webworker-packer/README.zh.md)在 Node 中为随包 Session 数据准备当前代际后继,并使用同一根目录中直属子 Session 的 descriptor 作为父目录证据。浏览器 host 未实现 `node:worker_threads`,因此打包阶段的准备同时保留不可变 fixture 代际与运行时 JSONL 的 Worker 校验要求。
Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。
@@ -127,6 +127,44 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
| 主线程重复执行完整 verification | 最多两个 complete-generation Worker | Verification CPU 不占用主线程 |
| Production 与 fixture 使用不同 migration API | Catalog `createRestore()` 加显式 policy | 只保留一套 decoder/chain 实现 |
### 父目录前置事实
V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并要求完整的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得的发现字段必须与父目录一致;缺失记录要求受支持的 descriptor。证据不足会拒绝迁移,而不是报告父日志损坏。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 `stat`/`list` 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。
通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。
<a id="catalog-scan-measurements"></a>
### 目录扫描测量
包内[诊断脚本](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts)测量一个原始 V3 父日志、四个各有 1,000 条事件的直属子日志,以及 0/100/1,000 个只有 header 的无关 V3 Session。每个样本在新进程中创建独立临时日志库与 Cordis 上下文,按 stat → 冷读 → 缓存读取 → 写入发布 → 当前格式热读顺序执行,最后释放上下文并删除文件。脚本在普通 Node 下使用已构建的工作区导出;创建夹具不计入测量区间。这些结果是夹具创建后的文件系统缓存热态观察,不代表磁盘冷读延迟,也不是性能门禁。
以下为 macOS arm64、Node v26.0.0 下各规模的全部三个样本,单位为毫秒。只读打开不发布后继:即使解码结果已缓存,成员扫描仍与日志库规模成正比。1,000 个无关日志时,中位数分别为 stat 84.14 ms、冷读 237.97 ms、缓存读取 110.98 ms、发布 268.46 ms,以及同进程当前格式读取 0.41 ms。这些测量记录成本,不证明性能提升,也不设跨主机阈值。
| 无关 Session | 历史 stat | 冷读 | 缓存读取 | 发布 | 当前格式热读 |
|---:|---:|---:|---:|---:|---:|
| 0 | 5.17 | 19.00 | 1.36 | 42.88 | 0.47 |
| 0 | 3.30 | 14.87 | 1.45 | 35.75 | 0.41 |
| 0 | 3.18 | 16.71 | 1.50 | 34.51 | 0.47 |
| 100 | 29.44 | 77.48 | 17.01 | 60.66 | 0.43 |
| 100 | 12.42 | 42.71 | 13.69 | 60.19 | 0.43 |
| 100 | 11.19 | 41.06 | 12.61 | 57.18 | 0.39 |
| 1000 | 84.14 | 237.97 | 109.86 | 284.96 | 0.48 |
| 1000 | 99.41 | 259.88 | 123.50 | 268.46 | 0.41 |
| 1000 | 75.40 | 236.64 | 110.98 | 247.69 | 0.39 |
在仓库根目录构建运行时后执行:
```sh
pnpm run build:lib:host
cd packages/session/session-persistence-jsonl
node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
node .artifacts/perf/catalog-migration.perf.mjs
```
## 验证
迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 5bcb6b4f1ef6d8d78c57beddd0c44e7c370d682a
config-catalog.zh.md: 868a9626081e2945c5075f350fd618dced87f725
config-catalog.md: 31f75d5dbc1569d759b8fbcde56dcc7ac3e85bc1
config-catalog.zh.md: e1d3064c1134443d0a3dc17011f068a84560a7d3
+2 -2
View File
@@ -1568,7 +1568,7 @@ export interface ReplayModelConfig {
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
Source: [`packages/test-support/llm-replay/src/index.ts:1122`](../packages/test-support/llm-replay/src/index.ts)
Source: [`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts)
<a id="deepseek-aidsh-llm-retry"></a>
@@ -2125,7 +2125,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
Source: [`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
Source: [`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts)
<a id="deepseek-aidsh-session-projection-cache"></a>
+2 -2
View File
@@ -1570,7 +1570,7 @@ export interface ReplayModelConfig {
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
来源:[`packages/test-support/llm-replay/src/index.ts:1122`](../packages/test-support/llm-replay/src/index.ts)
来源:[`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts)
<a id="deepseek-aidsh-llm-retry"></a>
@@ -2127,7 +2127,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
来源:[`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
来源:[`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts)
<a id="deepseek-aidsh-session-projection-cache"></a>
@@ -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 docs/persistence-changes/2026-09-16-session-format-v4.md
2026-09-16-session-format-v4.md: 57273fba4d45ff77537a4d5777f182eef406e156
2026-09-16-session-format-v4.zh.md: 26c15f688b0f68974ed07c720b1e4c1b168f1a6c
2026-09-16-session-format-v4.md: 1148545e767802b6f6c275b1df3e3f323ea92609
2026-09-16-session-format-v4.zh.md: 47d4564610da2185ecba55fbc25225abc7c75ede
@@ -35,12 +35,16 @@ changes:
<a id="compatibility"></a>
## Compatibility
The [V3-to-V4 migration](../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) advances the header and preserves every admitted event and inherited cut. Its generation-aware delivery checks prevent historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation; the catalog restores supported older inputs through the complete adjacent chain. The [V3 schema reference](historical-formats/v3.md) preserves the outgoing declarations, and persistence retains committed predecessor bytes while publishing only the current successor.
The [V3-to-V4 migration](../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) advances the header, preserves every admitted source event and inherited cut, and appends missing parent `subagent/catalog` records from retained direct-child logs in the same persistence root. Historical body restoration requires an explicit child-evidence set, including an empty set when no children are available to backfill. Missing required descriptors or conflicting identities refuse migration without publication; existing catalog facts remain. The linked specification owns descriptor admission and deterministic append order.
Historical read opens prepare the result in memory. Write opens revalidate child membership and revisions before publishing the current successor beside unchanged predecessor files. Already-written V4 files do not rerun this incoming edge, so unreleased integration uses disposable homes. Delivery-generation checks keep historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation; the [V3 schema reference](historical-formats/v3.md) preserves the outgoing declarations.
<a id="verification"></a>
## Verification
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests` passed 22 tests across two files, covering identity preservation, inherited cuts, admission, and delivery-generation refusal. `pnpm run verify-persistence-formats` passed for all five references from V0 through V4. Before the writer change, `pnpm run verify-persistence-catalog` passed and `pnpm run verify-persistence-formats --archive 3` captured the V3 schema.
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests packages/session/session-format-catalog/tests packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts` passed 140 tests across nine files, with one skipped test. The suites cover catalog completion, inherited cuts, child-evidence refusal, and JSONL preparation/publication.
Before the writer change, `pnpm run verify-persistence-catalog` passed and `pnpm run verify-persistence-formats --archive 3` captured the V3 schema. The V4 header schema digest and its generated companion remain unchanged.
<a id="dev-note"></a>
## Dev Note
@@ -35,12 +35,16 @@ changes:
<a id="compatibility"></a>
## 兼容性
[V3 到 V4 迁移](../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)推进 header,并保留每个已接纳事件与继承切点。其按 generation 校验 delivery 的规则可防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation;目录通过完整的相邻迁移链恢复受支持的旧输入。[V3 schema 参考](historical-formats/v3.zh.md)保留原声明,持久化在只发布当前后继代的同时保留已提交前代的字节。
[V3 到 V4 迁移](../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)推进 header,保留每个已接纳源事件与继承切点,并根据同一持久化根目录中保留的直接子 Session 日志追加缺失的父级 `subagent/catalog` 记录。历史正文恢复要求显式提供子日志证据集合;没有可供补全的子日志时也须传入空集合。缺少必需的 descriptor 或身份冲突会拒绝迁移,且不发布后继;已有 catalog 事实保持不变。链接中的规范负责 descriptor 准入与确定性追加顺序。
历史读取打开在内存中准备结果。写入打开在重新校验子项成员与修订后,将当前后继发布到未修改的前代文件旁。已写入的 V4 文件不会重新运行该迁入边,因此未发布集成使用可丢弃的 home。Delivery generation 校验防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation;[V3 schema 参考](historical-formats/v3.zh.md)保留原声明。
<a id="verification"></a>
## 验证
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests` 的两个文件共 22 个测试通过,覆盖恒等保留、继承切点、准入与 delivery generation 拒绝。`pnpm run verify-persistence-formats` 对从 V0 到 V4 的全部五个参考均验证通过。写入器变更前,`pnpm run verify-persistence-catalog` 通过,且 `pnpm run verify-persistence-formats --archive 3` 已捕获 V3 schema。
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests packages/session/session-format-catalog/tests packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts` 的九个文件共 140 个测试通过,一个测试跳过。这些测试覆盖 catalog 补全、继承切点、子日志证据拒绝,以及 JSONL 准备与发布。
写入器变更前,`pnpm run verify-persistence-catalog` 通过,且 `pnpm run verify-persistence-formats --archive 3` 已捕获 V3 schema。V4 header schema digest 及其生成的配套文件保持不变。
<a id="dev-note"></a>
## 开发备注
@@ -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/experimental/webworker-packer/README.md
README.md: 65a428cb82ec490cb1ff64432c372f912cc529de
README.zh.md: 8d3335dafb448fa6d2cd27395bb92dd97a4fb6c0
README.md: c927fd2f4b3bef3078a263f7b65bb0130898ef5b
README.zh.md: 2a10a8c0f51f7596d27b8c2ad92a1eeea6715ca8
@@ -37,7 +37,7 @@ The pack is a three-layer standard stack:
The repository adapter also declares the preview-only fixture trees under `webworker-runtime/tests/fixtures/`. The CLI packs each named fixture into a separate deterministic overlay archive plus a browser-readable manifest. Overlay files bypass npm publish-view and module-reachability exclusions, so dot directories and example source files remain intact; their mounts are limited to `home/` and `workspace/`. `packVfsOverlay` treats all files as opaque bytes.
`packPreviewFixture` prepares the newest canonical raw Session generation in each fixture directory through the [Session format catalog](../../session/session-format-catalog/README.md) in Node. It strictly restores the source, encodes and strictly validates the current successor, and packs that successor beside unchanged source files. A malformed, unsupported, or future selected generation refuses the build without falling back. Temporary successors are removed after packing. Browser write-open then uses current data without invoking Node's migration-verification Worker. Projection caches remain owner-validated; an older generation cannot supply a current fold checkpoint.
`packPreviewFixture` prepares the newest canonical raw Session generation in each fixture directory through the [Session format catalog](../../session/session-format-catalog/README.md) in Node. It collects direct-child discovery evidence from the same fixture root before completing historical parent catalogs. It strictly restores the source, encodes and strictly validates the current successor, and packs that successor beside unchanged source files. A malformed, unsupported, or future selected generation refuses the build without falling back. Temporary successors are removed after packing. Browser write-open then uses current data without invoking Node's migration-verification Worker. Projection caches remain owner-validated; an older generation cannot supply a current fold checkpoint.
-----
@@ -37,7 +37,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
仓库适配器还声明 `webworker-runtime/tests/fixtures/` 下仅用于 preview 的 fixture(测试前置数据) tree。CLI 会把每套具名 fixture 打成一份独立的确定性 overlay 归档,并写出浏览器可读的 manifest(元数据清单)。Overlay 文件绕过 npm 发布视图和模块可达性排除规则,因此点目录与示例源码会完整保留;其挂载位置仅限 `home/` 与 `workspace/`。`packVfsOverlay` 将所有文件视为不透明字节。
`packPreviewFixture` 在 Node 中通过 [Session 格式目录](../../session/session-format-catalog/README.zh.md)准备每个 fixture 目录里最新的规范原始 Session 代际。它严格恢复源文件、编码并严格校验当前后继文件,然后将后继文件与未改变的源文件一同打包。如果选中的代际格式错误、不受支持或属于未来版本,构建会拒绝,不回退。临时后继文件在打包后删除。浏览器以写模式打开时即可使用当前数据,无需调用 Node 的迁移校验 Worker。投影缓存仍由其所属包校验;较早代际不能提供当前折叠检查点。
`packPreviewFixture` 在 Node 中通过 [Session 格式目录](../../session/session-format-catalog/README.zh.md)准备每个 fixture 目录里最新的规范原始 Session 代际。它从同一 fixture 根目录收集直属子 Session 的发现证据,再补全历史父目录。它严格恢复源文件、编码并严格校验当前后继文件,然后将后继文件与未改变的源文件一同打包。如果选中的代际格式错误、不受支持或属于未来版本,构建会拒绝,不回退。临时后继文件在打包后删除。浏览器以写模式打开时即可使用当前数据,无需调用 Node 的迁移校验 Worker。投影缓存仍由其所属包校验;较早代际不能提供当前折叠检查点。
-----
@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-session-format": "workspace:^",
"@deepseek-ai/dsh-session-format-catalog": "workspace:^",
"@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:^",
"js-yaml": "^4.2.0",
"picomatch": "^4.0.4"
},
@@ -4,7 +4,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, posix } from 'node:path'
import { isSessionFormatJsonObject, parseSessionFormatLogFilename, sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { createSessionFormatCatalogWithChildren, historicalSessionFormatCatalog, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { historicalChildCatalogSource } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { packVfsOverlay, type ImageTree, type PackOverlayResult } from './pack.ts'
import type { ImageFiles } from './transform-image.ts'
@@ -26,7 +27,7 @@ interface PreviewSession {
export function packPreviewFixture(trees: readonly ImageTree[]): PackOverlayResult {
const original = packVfsOverlay(trees)
const successors = new Map<string, string>()
for (const source of selectedSessions(original.files)) {
const sources = selectedSessions(original.files).map((source) => {
const text = new TextDecoder('utf-8', { fatal: true }).decode(source.bytes)
if (!text.endsWith('\n')) throw new Error(`preview fixture: ${source.path} has a torn physical tail`)
const [headerLine = '', ...rows] = text.slice(0, -1).split('\n')
@@ -34,10 +35,20 @@ export function packPreviewFixture(trees: readonly ImageTree[]): PackOverlayResu
if (!isSessionFormatJsonObject(header) || header['version'] !== source.version || header['id'] !== source.id) {
throw new Error(`preview fixture: ${source.path} disagrees with its Session header`)
}
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
for (const row of rows) restore.decodeRow(JSON.parse(row))
const artifact = restore.finish()
const catalog = source.version <= 3 ? historicalSessionFormatCatalog : sessionFormatCatalog
const restore = catalog.createRestore(header, { recovery: 'strict', validation: 'current' })
const events: unknown[] = rows.map(row => JSON.parse(row) as unknown)
for (const event of events) restore.decodeRow(event)
return { ...source, header, events, artifact: restore.finish() }
})
for (const source of sources) {
if (source.version === sessionFormatCatalog.currentVersion) continue
const children = sources.filter(child => posix.dirname(child.directory) === posix.dirname(source.directory)
&& child.artifact.header.parentSession === source.id && child.artifact.header.origin === 'subagent')
.map(child => ({ ...historicalChildCatalogSource(child.artifact), sourcePath: child.path }))
const restore = createSessionFormatCatalogWithChildren(children).createRestore(source.header, { recovery: 'strict', validation: 'current' })
for (const event of source.events) restore.decodeRow(event)
const artifact = restore.finish()
const encodedHeader = sessionFormatCatalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount)
const encodedEvents = artifact.events.map(event => sessionFormatCatalog.encodeCurrentEvent(event))
const verification = sessionFormatCatalog.createRestore(encodedHeader, { recovery: 'strict', validation: 'current' })
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { createSessionFormatCatalogWithChildren, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format'
import { packPreviewFixture } from '../src/preview.ts'
import { packVfsOverlay } from '../src/pack.ts'
@@ -36,7 +36,7 @@ function source() {
function restore(bytes: Uint8Array | undefined) {
const [header, ...rows] = text(bytes).trimEnd().split('\n').map(row => JSON.parse(row) as unknown)
const reader = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
const reader = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' })
for (const row of rows) reader.decodeRow(row)
return reader.finish()
}
@@ -51,13 +51,44 @@ describe('Node preparation of Preview Session data', () => {
for (const [path, bytes] of Object.entries(original.files)) expect(projected.files[path], path).toEqual(bytes)
for (const path of currentFiles) {
const sourcePath = path.replace(/\/session\.v\d+\.jsonl$/u, '/session.v3.jsonl')
expect(restore(projected.files[path])).toEqual(restore(original.files[sourcePath]))
const historical = restore(original.files[sourcePath])
const current = restore(projected.files[path])
const appended = path.includes('/preview-showcase/') ? [
{ version: 0, childId: 'preview-architecture-review', childCreatedAt: 1787472100000, mode: 'one-shot', label: 'Review preview architecture' },
{ version: 0, childId: 'preview-follow-up-builder', childCreatedAt: 1787472200000, mode: 'continuable', label: 'Continue preview verification' },
].map((data, index) => ({ type: 'subagent/catalog', seq: historical.events.length + index,
time: historical.events.at(-1)!.time, data })) : []
expect(current).toEqual({ ...historical, events: [...historical.events, ...appended] })
expect(JSON.parse(text(projected.files[path]).split('\n')[0]!)).toMatchObject({ version: currentVersion })
}
expect(packPreviewFixture(fixture.trees).image).toEqual(projected.image)
expect(packVfsOverlay(fixture.trees).files).toEqual(original.files)
})
it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('collects only direct subagent children in the same root: %s', (mode) => {
const fixture = source()
const parent = fixture.put(3)
const childDirectory = join(fixture.trees[0]!.directory, 'sessions', mode === 'other-root' ? 'other' : 'project', 'child')
mkdirSync(childDirectory, { recursive: true })
const version = mode === 'current-child' ? currentVersion : 3
const child = { type: 'session', version, id: 'child', createdAt: 2, isSeeded: false,
delegationDepth: 1, parentSession: 'example', ...(mode === 'other-origin' ? {} : { origin: 'subagent' }) }
const descriptor = { type: 'subagent/descriptor', seq: 0, time: 3,
data: { version: 3, mode: 'one-shot', provider: 'spawn' } }
writeFileSync(join(childDirectory, sessionFormatLogFilename(version)),
JSON.stringify(child) + '\n' + (mode === 'current-child' ? JSON.stringify(descriptor) + '\n' : ''))
const before = readFileSync(parent)
if (mode === 'missing-descriptor') {
expect(() => packPreviewFixture(fixture.trees)).toThrow('requires exactly one own supported subagent descriptor')
} else {
const packed = packPreviewFixture(fixture.trees)
expect(restore(packed.files[`home/sessions/project/example/${sessionFormatLogFilename(currentVersion)}`]).events)
.toEqual(mode === 'current-child' ? [{ type: 'subagent/catalog', seq: 0, time: 1,
data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }] : [])
}
expect(readFileSync(parent)).toEqual(before)
})
it('uses the highest generation and leaves an already-current image byte-identical', () => {
const fixture = source()
const older = fixture.put(3, '{invalid historical data}\n')
@@ -25,6 +25,9 @@
},
{
"path": "../../session/session-format-catalog"
},
{
"path": "../../session/session-format-v3-to-v4"
}
]
}
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
import { SESSION_FORMAT_VERSION, Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { createSessionFormatCatalogWithChildren } from '@deepseek-ai/dsh-session-format-catalog'
import {
generationLogFilename,
scanLog,
@@ -41,7 +41,7 @@ function readSession(id: string): ReturnType<typeof scanLog> {
function restoreSession(content: string, version: number) {
const [header, ...events] = content.trimEnd().split('\n').map(line => JSON.parse(line) as unknown)
expect(header).toMatchObject({ type: 'session', version })
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' })
for (const event of events) restore.decodeRow(event)
return restore.finish()
}
@@ -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/session-format-catalog/README.md
README.md: 5ebfa82486873810f64d2f7bd3f83957bd995ed9
README.zh.md: 6e8d674b0dfd2e69c2ff2d0857afd04ab91734b5
README.md: 0666f13cef0a7f019ddd741666ee8c95f78af2b8
README.zh.md: b0132d2543f7f5a3a1d9ca5958e5c0593f3320e1
@@ -33,7 +33,8 @@ Import this library from persistence and test-support readers that need the comp
```text
const descriptor = sessionFormatCatalog.readHeader(physicalHeader)
const restore = sessionFormatCatalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' })
const catalog = createSessionFormatCatalogWithChildren(childFacts)
const restore = catalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' })
for (const row of physicalRows) restore.decodeRow(row)
const current = restore.finish()
const headerRecord = sessionFormatCatalog.encodeCurrentHeader(current.header, current.inheritedEventCount)
@@ -46,6 +47,8 @@ Production historical reads select `{ recovery: 'recoverable', validation: 'tran
The catalog contains all supported historical readers directly. A profile cannot add, remove, or reorder an edge by mounting a feature plugin. Its peer dependency on `dsh-session` supplies the installed current event vocabulary and current restoration rules, while historical edge validators remain frozen. The browser-safe `./message-projections` export assembles current plugin-owned interpreters for detached constructors and surface folds; it does not mount recovery listeners.
`createSessionFormatCatalogWithChildren(childFacts)` binds explicit child evidence to V3→V4 during assembly; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 only for collecting child prerequisites without recursively completing their catalogs. Isolated transcript replay explicitly supplies an empty array; persistence must collect the complete available direct-child set. Keep the supplied evidence unchanged for the catalog’s lifetime. Each restore owns independent stage state. The static `sessionFormatCatalog` supports header and native current-format reads; historical body reads require the child-bound catalog.
-----
<a id="understand-the-implementation"></a>
@@ -33,7 +33,8 @@ kind: "package-library"
```text
const descriptor = sessionFormatCatalog.readHeader(physicalHeader)
const restore = sessionFormatCatalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' })
const catalog = createSessionFormatCatalogWithChildren(childFacts)
const restore = catalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' })
for (const row of physicalRows) restore.decodeRow(row)
const current = restore.finish()
const headerRecord = sessionFormatCatalog.encodeCurrentHeader(current.header, current.inheritedEventCount)
@@ -46,6 +47,8 @@ Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transform
该目录直接包含所有受支持的历史读取器。Profile 无法通过挂载功能插件来添加、移除或重新排列迁移边。它通过对 `dsh-session` 的对等依赖(peer dependency)获得已安装的当前事件词表与当前还原规则,而历史迁移边校验器保持冻结。浏览器安全的 `./message-projections` 导出为独立构造函数和 surface 折叠装配当前插件拥有的处理器,不挂载恢复监听器。
`createSessionFormatCatalogWithChildren(childFacts)` 在组装时将显式的子 Session 证据绑定到 V3→V4,见[目录补齐规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 仅还原 V0–V3,用于收集子 Session 前置事实,避免递归补齐其目录。独立转录回放显式提供空数组;持久化必须收集完整的可用直属子 Session 集合。在 catalog 的生命周期内保持传入证据不变。每次恢复各自拥有独立的 stage 状态。静态 `sessionFormatCatalog` 支持 header 和原生当前格式读取;历史正文读取必须使用已绑定子 Session 的 catalog。
-----
<a id="understand-the-implementation"></a>
@@ -0,0 +1,19 @@
/** Bind parent-specific child evidence into the static first-party migration inventory. */
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatCatalog, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
import { createSessionFormatV3ToV4, sessionFormatV3ToV4 } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { sessionFormatCatalogOptions } from './generated.ts'
/**
* Assemble a catalog whose V3→V4 edge knows one parent's historical children.
* @param children - complete child evidence retained unchanged for the catalog's lifetime; an empty array declares no children.
* @returns a catalog with independent restore state per artifact and unchanged current-format readers.
*/
export function createSessionFormatCatalogWithChildren(children: readonly SessionFormatJsonValue[]): SessionFormatCatalog {
const migration = createSessionFormatV3ToV4(children)
return createSessionFormatCatalog({
...sessionFormatCatalogOptions,
migrations: sessionFormatCatalogOptions.migrations.map(edge => edge === sessionFormatV3ToV4 ? migration : edge),
})
}
@@ -5,14 +5,15 @@
import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatCatalogOptions } from '@deepseek-ai/dsh-session-format'
import { validateInstalledCurrentSessionArtifact, validateInstalledCurrentSessionHeader } from './current.ts'
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
import { releasedV2SessionFormatCodec, sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
import { releasedV3SessionFormatCodec, sessionFormatV2ToV3 } from '@deepseek-ai/dsh-session-format-v2-to-v3'
import { assertReleasedV4Header, releasedV4SessionFormatCodec, restoreReleasedV4Artifact, sessionFormatV3ToV4 } from '@deepseek-ai/dsh-session-format-v3-to-v4'
/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */
export const sessionFormatCatalog = createSessionFormatCatalog({
/** Static assembly shared by current reads and parent-specific historical restoration. */
export const sessionFormatCatalogOptions: SessionFormatCatalogOptions = {
currentVersion: 4,
codecs: [
releasedV0SessionFormatCodec,
@@ -41,4 +42,7 @@ export const sessionFormatCatalog = createSessionFormatCatalog({
validateInstalledCurrentSessionHeader(header)
return header
},
})
}
/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */
export const sessionFormatCatalog = createSessionFormatCatalog(sessionFormatCatalogOptions)
@@ -0,0 +1,21 @@
/** Historical restoration for collecting migration prerequisites without recursively opening current Sessions. */
import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
import { releasedV2SessionFormatCodec, sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
import { assertReleasedV3Header, releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '@deepseek-ai/dsh-session-format-v2-to-v3'
/** V0–V3 decoding for historical child identity; never publishes or completes parent catalogs. */
export const historicalSessionFormatCatalog = createSessionFormatCatalog({
currentVersion: 3,
codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec],
currentEncoder: releasedV3SessionFormatCodec,
migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3],
restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES),
restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES),
restoreCurrentHeader(header) {
assertReleasedV3Header(header)
return header
},
})
@@ -1,4 +1,6 @@
/** Build-static first-party Session format migration catalog. */
export { sessionFormatCatalog } from './generated.ts'
export { createSessionFormatCatalogWithChildren } from './children.ts'
export { historicalSessionFormatCatalog } from './historical.ts'
export { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '../src/index.ts'
import { createSessionFormatCatalogWithChildren, historicalSessionFormatCatalog, sessionFormatCatalog } from '../src/index.ts'
import { currentSessionMessageProjections } from '../src/message-projections.ts'
import { MESSAGE_PROJECTION_EVENT_TYPES } from '@deepseek-ai/dsh-session/src/known-event-types.ts'
import { validateInstalledCurrentSessionArtifact } from '../src/current.ts'
@@ -52,7 +52,7 @@ describe('first-party Session format catalog', () => {
})
const v1Header = { ...header, version: 1 }
const restore = sessionFormatCatalog.createRestore(v1Header, {
const restore = createSessionFormatCatalogWithChildren([]).createRestore(v1Header, {
recovery: 'strict', validation: 'current',
})
restore.decodeRow({ type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } })
@@ -66,7 +66,7 @@ describe('first-party Session format catalog', () => {
type: 'session', version: SESSION_FORMAT_VERSION, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
}
const restore = (rows: readonly unknown[]) => {
const current = sessionFormatCatalog.createRestore(header, {
const current = createSessionFormatCatalogWithChildren([]).createRestore(header, {
recovery: 'strict', validation: 'current',
})
for (const row of rows) current.decodeRow(row)
@@ -97,7 +97,7 @@ describe('first-party Session format catalog', () => {
type: 'session', version, id: 'seed-chain', createdAt: 1,
parentSession: 'parent', seedLength, delegationDepth: 0,
}
const restore = sessionFormatCatalog.createRestore(sourceHeader, { recovery: 'strict', validation: 'current' })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(sourceHeader, { recovery: 'strict', validation: 'current' })
if (seedLength > 0) {
restore.decodeRow({ type: 'feedback/record', seq: 0, time: 1, data: { text: 'inherited' } })
}
@@ -120,7 +120,7 @@ describe('first-party Session format catalog', () => {
...(isSeeded ? [{ type: 'session/end-seed', seq: 3, time: 4, data: { inherited: true } }] : []),
]
const before = JSON.stringify({ header, rows })
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' })
for (const row of rows) restore.decodeRow(row)
expect(restore.finish()).toEqual({
header: { version: SESSION_FORMAT_VERSION, id: 'v2-identity', createdAt: 1, isSeeded, delegationDepth: 0 },
@@ -149,7 +149,7 @@ describe('first-party Session format catalog', () => {
const header = { type: 'session', version: 2, id: 'v2-unknown', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const row = { type: 'external/event', seq: 0, time: 1, data: { extra: ['unchanged'] }, ignorable: true }
const before = JSON.stringify({ header, row })
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation })
expect(() => { restore.decodeRow(row) }).toThrow(/cannot safely transform unclassified event external\/event/)
expect(JSON.stringify({ header, row })).toBe(before)
})
@@ -184,7 +184,7 @@ describe('first-party Session format catalog', () => {
{ type: 'turn/end', seq: 9, time: 17, data: { turn: 1, reason: { kind: 'completed' } } },
])
const before = JSON.stringify({ sourceHeader, rows })
const restore = sessionFormatCatalog.createRestore(sourceHeader, { recovery: 'strict', validation: 'current' })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(sourceHeader, { recovery: 'strict', validation: 'current' })
for (const row of rows) restore.decodeRow(row)
const artifact = restore.finish()
const renamedMessage = (id: string) => ({ ...message(id), source: { kind: 'plugin', plugin: 'tools-ptc' } })
@@ -217,10 +217,10 @@ describe('first-party Session format catalog', () => {
const header = deepFreeze({ type: 'session', version: 3, id: 'native-ptc', createdAt: 1, isSeeded: false, delegationDepth: 0 })
for (const type of ['tool/code-dispatch-start', 'tool/code-dispatch']) {
const required = deepFreeze({ type, seq: 0, time: 1, data: null })
const rejected = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
const rejected = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation })
expect(() => { rejected.decodeRow(required) }).toThrow(/unknown event type/)
const ignorable = deepFreeze({ ...required, ignorable: true, data: { text: 'tools-code-mode', source: { kind: 'plugin', plugin: 'tools-code-mode' } } })
const accepted = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
const accepted = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation })
accepted.decodeRow(ignorable)
expect(accepted.finish().events).toEqual([ignorable])
}
@@ -230,7 +230,7 @@ describe('first-party Session format catalog', () => {
const header = deepFreeze({ type: 'session', version: 2, id: 'v2-collision', createdAt: 1, isSeeded: false, delegationDepth: 0 })
for (const type of ['tool/ptc-dispatch-start', 'tool/ptc-dispatch']) {
for (const ignorable of [false, true]) {
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation })
const row = deepFreeze({ type, seq: 0, time: 1, data: null, ...(ignorable ? { ignorable: true } : {}) })
expect(() => { restore.decodeRow(row) }).toThrow(/format v2.*ptc-dispatch/)
}
@@ -245,7 +245,7 @@ describe('first-party Session format catalog', () => {
{ type: 'tool/code-dispatch-start', seq: 1, time: 2, data, ignorable: true },
{ type: 'tool/code-dispatch', seq: 2, time: 3, data: { ...data, isError: false, content: [] }, ignorable: true },
])
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation })
for (const row of rows) restore.decodeRow(row)
expect(restore.finish().events).toEqual([
rows[0], { ...rows[1], type: 'tool/ptc-dispatch-start' }, { ...rows[2], type: 'tool/ptc-dispatch' },
@@ -254,7 +254,7 @@ describe('first-party Session format catalog', () => {
it.each(['current', 'transformed'] as const)('refuses a v3 delivery marker in v2 input (%s)', (validation) => {
const header = { type: 'session', version: 2, id: 'future-delivery', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation })
restore.decodeRow({ type: 'feedback/record', seq: 0, time: 1, data: { text: 'unaccepted' } })
expect(() => {
restore.decodeRow({ type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
@@ -264,7 +264,7 @@ describe('first-party Session format catalog', () => {
})
it('validates complete relationships after streaming migration', () => {
const stream = sessionFormatCatalog.createRestore({
const stream = createSessionFormatCatalogWithChildren([]).createRestore({
type: 'session', version: 1, id: 'invalid-stream', createdAt: 1, delegationDepth: 0,
}, { recovery: 'strict', validation: 'current' })
stream.decodeRow({ type: 'step/start', seq: 0, time: 2, data: { turn: 1, step: 1 } })
@@ -272,3 +272,15 @@ describe('first-party Session format catalog', () => {
expect(() => stream.finish()).toThrow(/open turn/)
})
})
describe('historical child prerequisite catalog', () => {
it('restores V3 and preceding versions without the incoming catalog-completion edge', () => {
const header = { type: 'session', version: 2, id: 'historical', createdAt: 1, isSeeded: false, delegationDepth: 0 }
expect(historicalSessionFormatCatalog.readHeader(header)).toMatchObject({ status: 'migration-required' })
for (const validation of ['current', 'transformed'] as const) {
const restored = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation }).finish()
expect(restored.header.version).toBe(3)
expect(historicalSessionFormatCatalog.createRestore({ ...header, version: 3 }, { recovery: 'strict', validation }).finish()).toEqual(restored)
}
})
})
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { createSessionFormatCatalogWithChildren, sessionFormatCatalog } from '../src/index.ts'
const header = { type: 'session', version: 3, id: 'parent', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const policy = { recovery: 'strict', validation: 'current' } as const
const child = (id: string) => ({ childId: id, childCreatedAt: 2, descriptorCount: 1,
descriptor: { version: 3, provider: 'spawn', mode: 'one-shot' } })
describe('parent-specific catalog assembly', () => {
it('requires explicit children for historical bodies while retaining header and native V4 reads', () => {
expect(sessionFormatCatalog.readHeader(header).status).toBe('migration-required')
expect(() => sessionFormatCatalog.createRestore(header, policy)).toThrow('explicit historical child facts')
expect(createSessionFormatCatalogWithChildren([]).createRestore(header, policy).finish().events).toEqual([])
expect(sessionFormatCatalog.createRestore({ ...header, version: 4 }, policy).finish().events).toEqual([])
})
it('isolates interleaved restores and child evidence across parent catalogs', () => {
const left = createSessionFormatCatalogWithChildren([child('left-child')])
const first = left.createRestore(header, policy)
const second = createSessionFormatCatalogWithChildren([child('right-child')])
.createRestore({ ...header, id: 'other-parent' }, policy)
const repeated = left.createRestore(header, policy)
const row = { type: 'feedback/record', seq: 0, time: 5, data: { text: 'retained' } }
first.decodeRow(row)
second.decodeRow(row)
repeated.decodeRow(row)
const right = second.finish()
const result = first.finish()
expect(result.events).toEqual([row, { type: 'subagent/catalog', seq: 1, time: 5,
data: { version: 0, childId: 'left-child', childCreatedAt: 2, mode: 'one-shot' } }])
expect(right.events[1]?.data).toMatchObject({ childId: 'right-child' })
expect(repeated.finish()).toEqual(result)
const physical = sessionFormatCatalog.encodeCurrentHeader(result.header, result.inheritedEventCount)
const reopened = sessionFormatCatalog.createRestore(physical, policy)
for (const event of result.events) reopened.decodeRow(sessionFormatCatalog.encodeCurrentEvent(event))
expect(reopened.finish()).toEqual(result)
})
})
@@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest'
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import { sessionFormatCatalog } from '../src/index.ts'
import { createSessionFormatCatalogWithChildren } from '../src/index.ts'
describe('catalog preset migration', () => {
it.each([0, 1, 2])('migrates a seeded v%i header and every inherited/local selection', (version) => {
@@ -19,7 +19,7 @@ describe('catalog preset migration', () => {
? [...rows.slice(0, 2), { type: 'session/end-seed', seq: 2, time: 2, data: { inherited: true } }, ...rows.slice(2)]
: rows
const before = JSON.stringify({ header, source })
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' })
for (const row of source) restore.decodeRow(row)
const artifact = restore.finish()
expect(artifact.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'code', agentPreset: 'ptc', isSeeded: true })
@@ -37,7 +37,7 @@ describe('catalog preset migration', () => {
delegationDepth: 0, agentPreset: 'code',
}
const row = { type: 'agent-preset/selected', seq: 0, time: 1, data: { agentPreset: 'code' } }
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
const restore = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' })
restore.decodeRow(row)
expect(restore.finish()).toMatchObject({ header: { agentPreset: 'code' }, events: [row] })
})
@@ -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/session-format-v3-to-v4/README.md
README.md: 97befb074bd6b756905368a88b621e4871d1ed0f
README.zh.md: 169ba020026d63e5d79bd2ac362968fff08cccfa
README.md: 4eada9da45fdd241b315feeee959705818c169b9
README.zh.md: 72c47cf5c1448275cc2eeb02a4d9baae00b47d2c
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Restore supported V3 Sessions as V4 without changing their events or historical requests. The library advances the header version, retains event coordinates and inherited cuts, and prevents historical delivery markers from becoming active V4 watermarks. Persistence consumes it through the static catalog; the library does not read or publish files.
`dsh-session-format-v3-to-v4` restores released V3 Sessions into the unreleased V4 integration format. The incoming edge preserves admitted events, appends missing parent catalog records, and inherits V3 framing and relationship validation. The static format catalog consumes this library; JSONL persistence owns source reads and successor publication.
## Table of Contents
@@ -41,11 +41,11 @@ Complete restoration decodes rows, passes them through a fresh stage, and valida
<a id="v3-to-v4-specification"></a>
## V3-to-V4 specification
The logical header changes only `version: 3` to `version: 4`. Every admitted event retains its type, payload, timestamp, sequence, message identity, surface operation, and references. The stage emits the original event object. It adds, removes, and reorders no events. The released V3 codec remains owned by [V2-to-V3](../session-format-v2-to-v3/README.md); this package reuses it without changing earlier migration semantics.
The logical header changes only `version: 3` to `version: 4`. Every admitted event retains its type, payload, timestamp, sequence, message identity, surface operation, and references. The stage emits the original event object. It appends missing own parent catalog records after the unchanged source events. The released V3 codec remains owned by [V2-to-V3](../session-format-v2-to-v3/README.md); this package reuses it without changing earlier migration semantics.
Source events must be dense from zero. A seeded Session's last `session/end-seed` carrying `inherited: true` determines its inherited event count, excluding the marker. A supplied source cut must agree. Missing seeded markers and inherited markers in unseeded Sessions are refused. Each stage owns its counters and cut; seeded stages leave the cut unknown before EOF, including when preceding migrations change event count.
Complete restoration applies [native V3 admission](../session-format-v2-to-v3/README.md#native-v3-admission) to the unchanged representation: malformed canonical events and invalid relationships are refused; unknown required event types require installed vocabulary; unknown ignorable events retain their opaque values. This identity edge changes no coordinates or payload meaning, so retaining opaque data does not require interpreting it. The separate source-audit policies of earlier edges remain unchanged.
Complete restoration applies [native V3 admission](../session-format-v2-to-v3/README.md#native-v3-admission) to the unchanged representation: malformed canonical events and invalid relationships are refused; unknown required event types require installed vocabulary; unknown ignorable events retain their opaque values. This edge changes no source coordinates or payload meaning, so retaining opaque data does not require interpreting it. The separate source-audit policies of earlier edges remain unchanged.
A `session-log-deepseek/delivery-accepted` event must identify a nonnegative safe-integer generation; omission identifies V0. A source marker claiming V4 is refused because advancing the header would activate it. Source V3 markers require a nonempty Session id and a `throughSeq` that names an earlier event. A foreign V3 marker is permitted only inside the inherited prefix of a Session with `parentSession`. Other generations retain their recorded coordinates and ids. No delivery payload is rewritten.
@@ -53,6 +53,23 @@ V4 restoration validates V4 delivery coordinates and ownership under the same ru
-----
### Entry point
```text
const catalog = createSessionFormatCatalogWithChildren(childFacts)
const restore = catalog.createRestore(physicalHeader, {
recovery: 'strict', validation: 'current',
})
for (const row of rows) restore.decodeRow(row)
const artifact = restore.finish()
```
`childFacts` is required explicitly; an empty array declares no children to backfill. The catalog uses `createSessionFormatV3ToV4(childFacts)` to bind this evidence in the stage factory; the generic migration interfaces carry no child data. Keep the evidence unchanged for the lifetime of the bound migration. `historicalChildCatalogSource()` collects a direct subagent child's id, creation time, own descriptor count, and descriptor payload after the inherited prefix. Collection permits absent or unknown descriptors. The Stage interprets descriptor v1 as continuable with a required label, and v2/v3 through their mode and label fields; it does not restore historical continuation composition.
An existing own parent catalog entry does not require an available child descriptor. Its extensions remain intact; creation time and available supported discovery fields must agree with the child. Duplicate own descriptors are refused. A missing catalog entry requires exactly one supported descriptor with complete discovery fields; missing or unknown descriptors refuse migration without publication. Native V4 reads also validate own catalog fields and uniqueness; malformed catalog data reports a format error rather than an incoming-migration failure. Catalog payload version 0 is the admitted schema; future payload evolution must update the codec’s admission and the repository’s persistence-change records together.
Migration retains every source event, order, sequence, timestamp, and payload, appending missing own catalog records sorted by creation time then child id. Appended records use the last source event time, or header creation time for an empty log. Catalog field and uniqueness checks apply only after the final inherited cut; inherited payloads remain opaque and do not establish own membership. Existing catalog extensions remain intact.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -83,11 +100,11 @@ The target restorer uses a private V3 relationship view to distinguish V4 delive
#### What the model sees
Historical requests retain their recorded messages and model configuration because the [identity stage](src/migration.ts) preserves `SessionFormatEvent.data`. This edge adds no model-visible content.
Historical requests retain their recorded messages and model configuration because the [migration stage](src/migration.ts) preserves `SessionFormatEvent.data`. This edge adds no model-visible content.
#### Token effect
The identity conversion changes no request text or token-bearing data.
Catalog records do not enter model messages directly; subsequent subagent listing can discover the historical children.
#### KV Cache effect
@@ -97,8 +114,9 @@ The edge preserves the recorded request prefix. Provider cache availability and
<a id="known-limitations-and-deferred-work"></a>
- **Existing V4 data skips this edge** — later edits to the unreleased migration do not transform an already-written V4 generation. Integration tests need fresh disposable homes populated from unchanged historical input.
- **No file publication** — the library returns validated logical events; persistence owns publishing a successor beside unchanged committed generations.
- **Unreleased integration format** — integrate and validate the selected structural changes before releasing V4; already-written V4 files do not rerun this incoming edge. Use disposable integration homes and retain the original historical inputs.
- **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain.
- **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration.
<a id="dev-note"></a>
### Dev Note
@@ -9,7 +9,7 @@ kind: "package-library"
## 概述
将受支持的 V3 Session 恢复为 V4,不改变其中的事件或历史请求。该库推进头部版本,保留事件坐标和继承切分点,并防止历史投递标记成为生效的 V4 水位。持久化通过静态目录使用本库;本库不读取或发布文件。
`dsh-session-format-v3-to-v4` 将已发布的 V3 Session 恢复为尚未发布的 V4 集成格式。入边保留已接纳事件,追加缺失的父目录记录,并继承 V3 分帧与关系校验。静态格式目录使用本库;JSONL 持久化负责源读取和后继代发布。
## 目录
@@ -41,11 +41,11 @@ const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader)
<a id="v3-to-v4-specification"></a>
## V3 到 V4 规范
逻辑头部只将 `version: 3` 改为 `version: 4`。每个获准事件都保留其类型、载荷、时间戳、序号、消息身份、surface 操作和引用。Stage 输出原始事件对象,不增删或重排事件。已发布 V3 编解码器仍由 [V2 到 V3](../session-format-v2-to-v3/README.zh.md)拥有;本包复用它,不改变较早迁移的语义。
逻辑头部只将 `version: 3` 改为 `version: 4`。每个获准事件都保留其类型、载荷、时间戳、序号、消息身份、surface 操作和引用。Stage 输出原始事件对象,并在未改变的源事件之后追加缺失的自身父目录记录。已发布 V3 编解码器仍由 [V2 到 V3](../session-format-v2-to-v3/README.zh.md)拥有;本包复用它,不改变较早迁移的语义。
源事件必须从零开始连续编号。带种子的 Session 中,最后一个携带 `inherited: true` 的 `session/end-seed` 决定继承事件数,不包含该标记本身。传入的源切分点必须与之相符。带种子却缺少标记,或不带种子却出现继承标记的 Session 均被拒绝。每个 Stage 独立拥有计数器和切分点;带种子的 Stage 在 EOF 前保持切分点未知,包括前序迁移改变事件数的情况。
完整恢复对未改变的表示应用[原生 V3 准入规则](../session-format-v2-to-v3/README.zh.md#native-v3-admission):拒绝格式错误的规范事件和无效关系;未知必需事件类型要求已安装的事件词表支持;未知可忽略事件保留其不透明值。该恒等迁移不改变坐标或载荷含义,因此保留不透明数据不需要解释其内容。较早迁移边各自的源审计策略保持不变。
完整恢复对未改变的表示应用[原生 V3 准入规则](../session-format-v2-to-v3/README.zh.md#native-v3-admission):拒绝格式错误的规范事件和无效关系;未知必需事件类型要求已安装的事件词表支持;未知可忽略事件保留其不透明值。该迁移不改变源坐标或载荷含义,因此保留不透明数据不需要解释其内容。较早迁移边各自的源审计策略保持不变。
`session-log-deepseek/delivery-accepted` 事件必须标识非负安全整数代际;省略时表示 V0。声称属于 V4 的源标记会被拒绝,因为推进头部会使它生效。源 V3 标记要求非空 Session id,以及指向较早事件的 `throughSeq`。属于其他 Session 的 V3 标记只允许出现在带有 `parentSession` 的 Session 的继承前缀中。其他代际保留记录的坐标和 id。任何投递载荷都不会被重写。
@@ -53,6 +53,23 @@ V4 恢复按相同规则校验 V4 投递坐标和归属。V3 投递在 V4 中属
-----
### 入口
```text
const catalog = createSessionFormatCatalogWithChildren(childFacts)
const restore = catalog.createRestore(physicalHeader, {
recovery: 'strict', validation: 'current',
})
for (const row of rows) restore.decodeRow(row)
const artifact = restore.finish()
```
`childFacts` 必须显式提供;空数组声明没有可补齐的子 Session。catalog 使用 `createSessionFormatV3ToV4(childFacts)` 在 stage 工厂中绑定这些证据;通用迁移接口不传递子 Session 数据。在已绑定迁移的生命周期内保持证据不变。`historicalChildCatalogSource()` 收集直属 subagent 子 Session 的 id、创建时间、继承前缀之后的自身 descriptor 数量及载荷。收集阶段允许 descriptor 缺失或版本未知。Stage 将 descriptor v1 解释为必须带 label 的 continuable,按 v2/v3 的 mode 与 label 字段解释这两个版本;它不恢复历史 continuation composition。
父 Session 已有的自身目录记录不要求子 descriptor 可用。记录中的扩展字段保持不变;创建时间与可获得的受支持发现字段必须与子 Session 一致。重复的自身 descriptor 会被拒绝。缺失的目录记录要求恰好一个受支持且发现字段完整的 descriptor;descriptor 缺失或版本未知会拒绝迁移且不发布后继版本。原生 V4 读取也检查自身目录字段及唯一性;畸形目录数据报告格式错误,而不是入边迁移失败。目录载荷版本 0 是准入模式;后续载荷演进必须同时更新编解码器准入规则与仓库的持久化变更记录。
迁移保留每个来源事件及其顺序、序号、时间和 payload,仅按创建时间、子 id 排序追加缺失的自身目录记录。追加时间使用来源最后一个事件的时间,空日志使用 header 创建时间。目录字段与唯一性检查仅作用于最终继承截点之后的记录;继承的 payload 保持不透明,也不计入自身成员关系。已有目录的扩展字段原样保留。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -83,11 +100,11 @@ V4 恢复按相同规则校验 V4 投递坐标和归属。V3 投递在 V4 中属
#### 模型看到什么
历史请求保留记录的消息与模型配置,因为[恒等迁移 Stage](src/migration.ts)保留 `SessionFormatEvent.data`。该迁移边不添加模型可见内容。
历史请求保留记录的消息与模型配置,因为[迁移 Stage](src/migration.ts)保留 `SessionFormatEvent.data`。该迁移边不添加模型可见内容。
#### Token 影响
恒等转换不改变请求文本或承载 token 的数据。
目录记录不直接进入模型消息;后续子代理列举可以发现历史子 Session。
#### KV Cache 影响
@@ -97,8 +114,9 @@ V4 恢复按相同规则校验 V4 投递坐标和归属。V3 投递在 V4 中属
<a id="known-limitations-and-deferred-work"></a>
- **现有 V4 数据跳过此迁移边** — 对尚未发布迁移的后续修改不会转换已经写出的 V4 代际。集成测试需要使用新的临时 home,并从未改变的历史输入初始化。
- **不发布文件** — 本库返回已校验的逻辑事件;持久化负责在不改变已提交代际的前提下发布后继文件。
- **尚未发布的集成格式**——发布 V4 前须集成并验证选定的结构变更;已写入的 V4 文件不会重跑此入边。使用可丢弃的集成 home,并保留原始历史输入。
- **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。
- **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。
<a id="dev-note"></a>
### 开发备注
@@ -0,0 +1,88 @@
/** Historical child identity reduced to the fields required by a parent's catalog. */
import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatArtifact, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
/**
* Collect a child's own descriptor without requiring one before its parent catalog is read.
* @param artifact - validated historical child artifact with its exact inherited cut.
* @returns compact child identity and descriptor evidence for catalog completion.
*/
export function historicalChildCatalogSource(artifact: SessionFormatArtifact): SessionFormatJsonObject {
const header = artifact.header
if (header.origin !== 'subagent' || header.parentSession === undefined) {
throw new SessionFormatUnsupportedMigrationError('catalog migration requires a subagent child with a direct parent')
}
const descriptors = artifact.events.filter(event => event.type === 'subagent/descriptor' && event.seq >= artifact.inheritedEventCount)
return {
childId: header.id, childCreatedAt: header.createdAt,
descriptorCount: descriptors.length, descriptor: descriptors[0]?.data ?? null,
}
}
/**
* Validate supplemental child evidence supplied across the migration JSON interface.
* @param value - collected child identity and optional descriptor.
* @returns validated evidence; descriptor interpretation is deferred until parent membership is known.
*/
export function childCatalogSource(value: SessionFormatJsonValue): SessionFormatJsonObject {
if (!isSessionFormatJsonObject(value) || typeof value['childId'] !== 'string'
|| !Object.hasOwn(value, 'descriptor')) {
throw new SessionFormatUnsupportedMigrationError('catalog migration requires historical child identity and descriptor evidence')
}
sessionFormatCount(value['childCreatedAt'], 'catalog child creation time')
sessionFormatCount(value['descriptorCount'], 'child descriptor count')
return value
}
/**
* Interpret only the discovery fields of known historical descriptors.
* @param source - validated child evidence.
* @returns a catalog fact, or undefined when an existing entry needs no unavailable descriptor.
*/
export function childCatalogFact(source: SessionFormatJsonObject): SessionFormatJsonObject | undefined {
const id = source['childId'] as string
const descriptor = source['descriptor']
const count = source['descriptorCount'] as number
const known = isSessionFormatJsonObject(descriptor) && [1, 2, 3].includes(descriptor['version'] as number)
if (count > 1) {
throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} requires exactly one own supported subagent descriptor to complete its parent catalog`)
}
if (count === 0 || !known) return undefined
if (typeof descriptor['provider'] !== 'string') {
throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor provider`)
}
return catalogFact({
version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number,
// V1 described only continuable children and carried no mode field.
mode: descriptor['version'] === 1 ? 'continuable' : descriptor['mode'] as SessionFormatJsonValue,
...(descriptor['label'] === undefined ? {} : { label: descriptor['label'] }),
}, childCatalogSubject(source))
}
/**
* Validate the historical fields used for catalog membership without interpreting extensions.
* @param value - catalog payload or prepared historical discovery fact.
* @param subject - event type or child identity used in rejection diagnostics.
* @returns validated catalog payload.
*/
export function catalogFact(value: SessionFormatJsonValue, subject = 'subagent/catalog'): SessionFormatJsonObject {
if (!isSessionFormatJsonObject(value) || value['version'] !== 0
|| typeof value['childId'] !== 'string'
|| (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot')
|| (value['mode'] === 'continuable' && typeof value['label'] !== 'string')
|| (value['label'] !== undefined && typeof value['label'] !== 'string')) {
throw new SessionFormatError(`${subject} requires a complete version 0 catalog fact`)
}
sessionFormatCount(value['childCreatedAt'], 'catalog child creation time')
return value
}
/**
* Name the child and its optional storage-owned location in a migration diagnostic.
* @param source - validated supplemental child evidence.
* @returns a child identity with its source path when persistence supplied one.
*/
export function childCatalogSubject(source: SessionFormatJsonObject): string {
return `Session ${source['childId'] as string}${typeof source['sourcePath'] === 'string' ? ` (raw log: ${source['sourcePath']})` : ''}`
}
@@ -1,6 +1,7 @@
/** Identity V3-to-V4 migration with frozen V3 framing and generation-aware delivery validation. */
/** V3 framing and adjacent historical parent-catalog migration into V4. */
export { releasedV3SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v2-to-v3'
export * from './codec.ts'
export * from './migration.ts'
export { assertReleasedV4Header, restoreReleasedV4Artifact } from './validation.ts'
export { assertReleasedV4Header, assertReleasedV4Relationships, restoreReleasedV4Artifact } from './validation.ts'
export { historicalChildCatalogSource } from './facts.ts'
@@ -1,11 +1,12 @@
/** Streaming V3-to-V4 identity conversion with inherited cuts and delivery activation guards. */
/** Append historical child facts after the unchanged V3 source suffix. */
import { SessionFormatError, SessionFormatUnsupportedMigrationError, defineSessionFormatMigration, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatJsonObject, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format'
import { defineSessionFormatMigration, SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatJsonObject, SessionFormatJsonValue, SessionFormatMigration, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format'
import { assertReleasedV3Header } from '@deepseek-ai/dsh-session-format-v2-to-v3'
import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts'
import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts'
/** Adjacent migration preserves admitted V3 event values and coordinates. */
/** Header-only migration declaration; body restoration requires explicit child evidence. */
export const sessionFormatV3ToV4 = defineSessionFormatMigration({
name: '@deepseek-ai/dsh-session-format-v3-to-v4',
fromVersion: 3,
@@ -14,26 +15,51 @@ export const sessionFormatV3ToV4 = defineSessionFormatMigration({
assertReleasedV3Header(header)
return { ...header, version: 4 }
},
createStage(input) { return new ReleasedV3ToV4Stage(input) },
createStage() {
throw new SessionFormatUnsupportedMigrationError('V3 catalog migration requires explicit historical child facts, including an empty array for a parent without children')
},
validateTargetHeader: assertReleasedV4Header,
})
/**
* Bind one parent's historical child evidence to its V3→V4 migration.
* @param children - complete child evidence retained unchanged for the lifetime of this declaration; an empty array declares no children.
* @returns an adjacent migration that creates independent stages with the supplied evidence.
*/
export function createSessionFormatV3ToV4(children: readonly SessionFormatJsonValue[]): SessionFormatMigration {
return defineSessionFormatMigration({
...sessionFormatV3ToV4,
createStage: input => new ReleasedV3ToV4Stage(input, children),
})
}
class ReleasedV3ToV4Stage implements SessionFormatMigrationStage {
readonly headerInheritedEventCount?: number
private readonly candidates: readonly SessionFormatJsonObject[]
private readonly catalogs: SessionFormatJsonValue[] = []
private cut: number | undefined
private nextSeq = 0
private time: number
private foreignDeliverySeq: number | undefined
constructor(private readonly input: SessionFormatMigrationStageInput) {
constructor(private readonly input: SessionFormatMigrationStageInput, children: readonly SessionFormatJsonValue[]) {
this.candidates = children.map(childCatalogSource).sort((left, right) =>
(left['childCreatedAt'] as number) - (right['childCreatedAt'] as number)
|| (left['childId'] === right['childId'] ? 0 : (left['childId'] as string) < (right['childId'] as string) ? -1 : 1))
this.cut = input.sourceHeader.isSeeded ? undefined : 0
if (!input.sourceHeader.isSeeded) this.headerInheritedEventCount = 0
this.time = input.sourceHeader.createdAt
}
transformEvent(event: SessionFormatEvent, context: SessionFormatMigrationContext): void {
if (event.seq !== this.nextSeq++) throw new SessionFormatError('format v3 source events must be dense')
if (event.type === 'session/end-seed' && (event.data as SessionFormatJsonObject)['inherited'] === true) {
if (event.seq !== this.nextSeq++) throw new SessionFormatError('V3 source events must be dense')
this.time = event.time
if (event.type === 'session/end-seed' && isSessionFormatJsonObject(event.data) && event.data['inherited'] === true) {
if (!this.input.sourceHeader.isSeeded) throw new SessionFormatError('unseeded format v3 Session contains an inherited end-seed marker')
this.cut = event.seq
this.catalogs.length = 0
} else if (event.type === 'subagent/catalog') {
this.catalogs.push(event.data)
}
const deliveryId = validateDeliveryAccepted(event, 3)
if (event.type === 'session-log-deepseek/delivery-accepted') {
@@ -49,8 +75,16 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage {
for (const event of run.expand()) this.transformEvent(event, context)
}
finish(_context: SessionFormatMigrationContext): number {
const cut = sessionFormatCount(this.cut, 'format v3 inherited event count')
finish(context: SessionFormatMigrationContext): number {
const cut = sessionFormatCount(this.cut, 'V3 inherited event count')
// Catalog payloads belong to this Session only after the final inherited cut.
const existingCatalogs = new Map<string, SessionFormatJsonObject>()
for (const data of this.catalogs) {
const fact = catalogFact(data)
const id = fact['childId'] as string
if (existingCatalogs.has(id)) throw new SessionFormatUnsupportedMigrationError(`duplicate catalog child ${id}`)
existingCatalogs.set(id, fact)
}
if (this.input.sourceInheritedEventCount !== undefined && cut !== this.input.sourceInheritedEventCount) {
throw new SessionFormatError('format v3 inherited cut disagrees with its source marker')
}
@@ -58,6 +92,23 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage {
&& (this.input.sourceHeader.parentSession === undefined || this.foreignDeliverySeq >= cut)) {
throw new SessionFormatError('current-generation delivery marker names the wrong Session')
}
for (const source of this.candidates) {
const id = source['childId'] as string
const existing = existingCatalogs.get(id)
const fact = childCatalogFact(source)
if (existing !== undefined) {
if (existing['childCreatedAt'] !== source['childCreatedAt']) {
throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} conflicts with its parent catalog`)
}
if (fact !== undefined && ['mode', 'label'].some(key => existing[key] !== fact[key])) {
throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} conflicts with its parent catalog`)
}
continue
}
if (fact === undefined) throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} requires exactly one own supported subagent descriptor to complete its parent catalog`)
existingCatalogs.set(id, fact)
context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: fact })
}
return cut
}
}
@@ -3,6 +3,7 @@
import { SessionFormatError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { assertReleasedV3Header, restoreReleasedV3Artifact } from '@deepseek-ai/dsh-session-format-v2-to-v3'
import { catalogFact } from './facts.ts'
/**
* Validate V4 metadata with the unchanged released V3 header fields.
@@ -21,8 +22,8 @@ export function assertReleasedV4Header(header: SessionFormatHeader): void {
*/
export function restoreReleasedV4Artifact(artifact: SessionFormatArtifact, knownEventTypes: ReadonlySet<string>): SessionFormatArtifact {
assertReleasedV4Header(artifact.header)
assertReleasedV4Relationships(artifact)
const events = artifact.events.map((event): SessionFormatEvent => {
validateDeliveryAccepted(event, 4)
if (event.type !== 'session-log-deepseek/delivery-accepted') return event
const data = event.data as SessionFormatJsonObject
const version = data['sessionFormatVersion']
@@ -53,3 +54,25 @@ export function validateDeliveryAccepted(event: SessionFormatEvent, currentVersi
if (typeof id !== 'string' || id.length === 0) throw new SessionFormatError('delivery requires a nonempty Session id')
return id
}
/**
* Validate V4 catalog membership and delivery ownership without changing vocabulary or turn recovery policies.
* @param artifact - decoded artifact with its final inherited cut.
*/
export function assertReleasedV4Relationships(artifact: SessionFormatArtifact): void {
const ids = new Set<string>()
for (const event of artifact.events) {
const deliveryId = validateDeliveryAccepted(event, 4)
if (deliveryId !== undefined
&& !(artifact.header.parentSession !== undefined && event.seq < artifact.inheritedEventCount)
&& deliveryId !== artifact.header.id) {
throw new SessionFormatError('current-generation delivery marker names the wrong Session')
}
if (event.type === 'subagent/catalog' && event.seq >= artifact.inheritedEventCount) {
const fact = catalogFact(event.data)
const id = fact['childId'] as string
if (ids.has(id)) throw new SessionFormatError(`duplicate catalog child ${id}`)
ids.add(id)
}
}
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { catalogFact, childCatalogFact } from '../src/facts.ts'
import type { SessionFormatEvent, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
import { createSessionFormatV3ToV4, historicalChildCatalogSource, sessionFormatV3ToV4 } from '../src/index.ts'
const header = { version: 3, id: 'parent', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const child = { version: 0, childId: 'child', childCreatedAt: 2, mode: 'continuable', label: 'child task' }
function migrate(events: SessionFormatEvent[], facts: readonly SessionFormatJsonValue[], seeded = false, cut?: number, run = false) {
const migration = createSessionFormatV3ToV4(facts.map((value) => {
if (!isSessionFormatJsonObject(value) || !('version' in value)) return value
const fact = catalogFact(value)
return { childId: fact['childId']!, childCreatedAt: fact['childCreatedAt']!, descriptorCount: 1,
descriptor: { version: 3, provider: 'spawn', mode: fact['mode']!, ...(fact['label'] === undefined ? {} : { label: fact['label'] }) } }
}))
const stage = migration.createStage({
sourceHeader: { ...header, isSeeded: seeded, ...(seeded ? { parentSession: 'ancestor' } : {}) }, targetHeader: { ...header, version: 4, isSeeded: seeded },
sourceKind: 'decoded', sourceInheritedEventCount: cut ?? (seeded ? undefined : 0),
})
const output: SessionFormatEvent[] = []
const context = { emitEvent: (event: SessionFormatEvent) => { output.push(event) }, emitRun: () => { throw new Error('unexpected run') } }
if (run) stage.transformRun({ runType: 'test', firstSeq: 0, eventCount: events.length, *expand() { yield* events } }, context)
else for (const event of events) stage.transformEvent(event, context)
return { cut: stage.finish(context), events: output }
}
describe('V3 parent catalog completion', () => {
it('appends deterministic child facts without changing source events', () => {
const source = [{ type: 'feedback/record', seq: 0, time: 10, data: { text: 'retained' } }]
const result = migrate(source, [child])
expect(result.events[0]).toBe(source[0])
expect(result.events[1]).toEqual({ type: 'subagent/catalog', seq: 1, time: 10, data: child })
expect(migrate(source, [child])).toEqual(result)
expect(source).toHaveLength(1)
})
it('preserves existing facts and adds only missing children', () => {
const source = [{ type: 'subagent/catalog', seq: 0, time: 5, data: child }]
expect(migrate(source, [child, child]).events).toEqual(source)
expect(migrate(source, [child, { ...child, childId: 'second' }]).events).toHaveLength(2)
})
it('does not treat inherited catalog membership as owned', () => {
const source = [
{ type: 'subagent/catalog', seq: 0, time: 1, data: child },
{ type: 'session/end-seed', seq: 1, time: 2, data: { inherited: true } },
]
const result = migrate(source, [child], true)
expect(result.cut).toBe(1)
expect(result.events[2]).toMatchObject({ type: 'subagent/catalog', seq: 2, data: child })
})
it('validates catalog payloads only after the final inherited seed marker', () => {
const opaque = { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 99 } }
const seed = { type: 'session/end-seed', seq: 1, time: 2, data: { inherited: true } }
const source = [opaque, seed, { ...opaque, seq: 2 }, { ...seed, seq: 3 }]
expect(migrate(source, [child], true).events).toEqual([
...source, { type: 'subagent/catalog', seq: 4, time: 2, data: child },
])
expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('complete version 0')
expect(() => migrate([opaque], [])).toThrow('complete version 0')
})
it('requires explicit corpus facts and refuses conflicting identities', () => {
expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 },
sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts')
expect(() => migrate([], [{ ...child, mode: 'unknown' }])).toThrow('complete version 0')
expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts')
})
it('extracts the own descriptor after a fork prefix', () => {
const artifact = {
header: { ...header, id: 'child', createdAt: 2, origin: 'subagent' as const, parentSession: 'parent', isSeeded: true },
inheritedEventCount: 1,
events: [
{ type: 'subagent/descriptor', seq: 0, time: 1, data: { version: 3, mode: 'one-shot', provider: 'fork' } },
{ type: 'subagent/descriptor', seq: 1, time: 2, data: { version: 3, mode: 'continuable', provider: 'fork', label: 'child task' } },
],
}
expect(childCatalogFact(historicalChildCatalogSource(artifact))).toEqual(child)
expect(() => migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })])).toThrow('exactly one')
expect(() => migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })])).toThrow('exactly one')
})
it('orders tied and differently dated children independently of directory enumeration', () => {
const children = [{ ...child, childId: 'z' }, { ...child, childId: 'a' }, { ...child, childId: 'first', childCreatedAt: 1 }]
expect(migrate([], children).events.map(event => event.data)).toEqual([children[2], children[1], children[0]])
expect(migrate([], children).events[0]?.time).toBe(1)
})
it('rejects duplicates, sparse events, and missing or inconsistent seed cuts', () => {
const catalog = { type: 'subagent/catalog', seq: 0, time: 1, data: child }
expect(() => migrate([catalog, { ...catalog, seq: 1 }], [])).toThrow('duplicate')
expect(() => migrate([{ ...catalog, seq: 1 }], [])).toThrow('dense')
expect(() => migrate([], [], true)).toThrow('inherited event count')
expect(() => migrate([], [], false, 1)).toThrow('disagrees')
expect(migrate([catalog], [], false, undefined, true).events).toEqual([catalog])
})
it('rejects target delivery activation and invalid current delivery ownership', () => {
const preceding = { type: 'feedback/record', seq: 0, time: 1, data: { text: 'prior' } }
const delivery = { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 1,
data: { sessionId: 'other', throughSeq: 0, sessionFormatVersion: 4 } }
expect(() => migrate([preceding, delivery], [])).toThrow('claims target format v4')
expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session')
})
it('requires supported descriptors and complete discovery fields', () => {
const artifact = { header: { ...header, origin: 'subagent' as const, parentSession: 'parent' }, inheritedEventCount: 0, events: [] }
expect(() => historicalChildCatalogSource({ ...artifact, header })).toThrow('direct parent')
for (const data of [null, {}, { version: 4, mode: 'one-shot', provider: 'spawn' }]) {
expect(() => migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })])).toThrow('supported subagent descriptor')
}
for (const value of [null, { ...child, version: 1 }, { ...child, childId: null },
{ ...child, label: null }, { ...child, childCreatedAt: -1 }]) {
expect(() => migrate([], [value])).toThrow()
}
expect(migrate([], [{ version: 0, childId: 'one-shot', childCreatedAt: 2, mode: 'one-shot' }]).events).toHaveLength(1)
})
it.each([null, { version: 99 }])('retains existing catalog extensions without interpreting unavailable descriptors: %j', (descriptor) => {
const data = { ...child, extension: 'retained' }
const source = [{ type: 'subagent/catalog', seq: 0, time: 1, data }]
expect(migrate(source, [{ childId: 'child', childCreatedAt: 2, descriptorCount: descriptor === null ? 0 : 1, descriptor }]).events).toEqual(source)
})
it('refuses malformed supplemental evidence and known descriptor conflicts', () => {
for (const source of [null, {}, { childId: 'child', childCreatedAt: 2, descriptor: null, descriptorCount: -1 }]) {
expect(() => migrate([], [source])).toThrow()
}
const source = { childId: 'child', childCreatedAt: 2, descriptorCount: 1,
descriptor: { version: 3, mode: 'continuable', label: 'child task' } }
expect(() => migrate([], [source])).toThrow('invalid subagent descriptor provider')
const existing = [{ type: 'subagent/catalog', seq: 0, time: 1, data: child }]
expect(() => migrate(existing, [{ ...source, descriptor: { ...source.descriptor, provider: 'spawn', label: 'different' } }])).toThrow('conflicts')
})
it('validates predecessor delivery before preserving its generation identity', () => {
const prior = { type: 'feedback/record', seq: 0, time: 1, data: { text: 'prior' } }
const marker = { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 1,
data: { sessionId: 'ancestor', throughSeq: 0, sessionFormatVersion: 3 } }
const seed = { type: 'session/end-seed', seq: 2, time: 2, data: { inherited: true } }
expect(migrate([prior, marker, seed], [], true).events).toEqual([prior, marker, seed])
expect(() => migrate([prior, { ...seed, seq: 1 }, { ...marker, seq: 2 }], [], true)).toThrow('wrong Session')
expect(migrate([prior, { ...marker, data: { ...marker.data, sessionFormatVersion: 2 } }], []).events).toHaveLength(2)
expect(migrate([prior, { ...marker, data: { ...marker.data, sessionId: 'parent' } }], []).events).toHaveLength(2)
expect(childCatalogFact(historicalChildCatalogSource({ header: { ...header, origin: 'subagent', parentSession: 'ancestor' }, inheritedEventCount: 0,
events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data: { version: 3, mode: 'one-shot', provider: 'spawn' } }] }))).toEqual({ version: 0, childId: 'parent', childCreatedAt: 1, mode: 'one-shot' })
})
})
@@ -7,7 +7,8 @@ import { assertReleasedV4Header, assertV4RowAdmission, releasedV3SessionFormatCo
const header = { version: 4, id: 'parent', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const fact: SessionFormatEvent = { type: 'feedback/record', seq: 0, time: 1, data: { text: 'retained' } }
const types = new Set(['feedback/record', 'session-log-deepseek/delivery-accepted', 'session/end-seed'])
const catalog: SessionFormatEvent = { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }
const types = new Set(['subagent/catalog', 'feedback/record', 'session-log-deepseek/delivery-accepted', 'session/end-seed'])
const delivery = (version: number | undefined, sessionId = header.id): SessionFormatEvent => ({
type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
data: { sessionId, throughSeq: 0, ...(version === undefined ? {} : { sessionFormatVersion: version }) },
@@ -113,4 +114,13 @@ describe('V4 framing and restoration', () => {
expect(decoder.finish(output)).toBe(0)
expect(output.values).toEqual([fact])
})
it('rejects duplicate own catalog membership', () => {
expect(() => restore([catalog, { ...catalog, seq: 1 }])).toThrow('duplicate catalog child')
})
it('keeps inherited catalog entries outside own uniqueness checks', () => {
const artifact = { header: { ...header, isSeeded: true, parentSession: 'ancestor' }, inheritedEventCount: 1,
events: [catalog, { type: 'session/end-seed', seq: 1, time: 2, data: { inherited: true } }, { ...catalog, seq: 2 }] }
expect(restoreReleasedV4Artifact(artifact, types)).toBe(artifact)
})
})
@@ -0,0 +1,103 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { expect, it } from 'vitest'
import { createSessionFormatCatalogWithChildren, historicalSessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { isSessionFormatJsonObject, type SessionFormatArtifact, type SessionFormatJsonObject, type SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
import { historicalChildCatalogSource } from '../src/index.ts'
const root = resolve(import.meta.dirname, '../../../..')
function restoreFixture(path: string): SessionFormatArtifact {
const records = readFileSync(join(root, path), 'utf8').trim().split('\n').map(line => JSON.parse(line) as Record<string, SessionFormatJsonValue>)
const header = records.shift()!
if (typeof header['cwd'] === 'string' && header['cwd'].startsWith('{{cwd}}')) header['cwd'] = resolve('snapshot-cwd')
const restore = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
for (const [seq, record] of records.entries()) {
if (record['type'] === 'request/header' && isSessionFormatJsonObject(record['data']) && isSessionFormatJsonObject(record['data']['header'])) {
const request = { ...record['data']['header'] }
record['data'] = { ...record['data'], header: request }
if (request['tools'] === '{{tools}}') delete request['tools']
else if (Array.isArray(request['tools']) && request['tools'].every(tool => typeof tool === 'string')) {
request['tools'] = request['tools'].map(name => ({ name, description: '', parameters: {} }))
}
}
restore.decodeRow({ ...record, seq: record['seq'] ?? seq, time: record['time'] ?? 0 })
}
return restore.finish()
}
function migrate(parent: SessionFormatArtifact, children: SessionFormatArtifact[]) {
const restore = createSessionFormatCatalogWithChildren(children.map(historicalChildCatalogSource)).createRestore({ type: 'session', ...parent.header }, {
recovery: 'strict', validation: 'current',
})
for (const event of parent.events) restore.decodeRow(event)
return restore.finish()
}
it('refuses recorded parent/child clock conflicts and migrates consistent copies including failed startup', () => {
const conflicts: string[] = []
let pairs = 0
let withoutDescriptor = 0
for (const profile of ['sdk', 'session']) {
const base = `snapshots/${profile}`
for (const dir of readdirSync(join(root, base), { withFileTypes: true })) {
if (!dir.isDirectory()) continue
const names = readdirSync(join(root, base, dir.name)).filter(name => /^session(?:\.\d+)?\.v3\.jsonl$/.test(name))
if (names.length < 2) continue
const logs = names.map(name => ({ path: `${base}/${dir.name}/${name}`, artifact: restoreFixture(`${base}/${dir.name}/${name}`) }))
for (const parent of logs) {
const children = logs.filter(child => child.artifact.header.origin === 'subagent' && child.artifact.header.parentSession === parent.artifact.header.id)
if (children.length === 0) continue
pairs += children.length
const catalogs = parent.artifact.events.filter(event => event.type === 'subagent/catalog' && event.seq >= parent.artifact.inheritedEventCount)
const aligned = children.map((child) => {
const catalog = catalogs.find(event => isSessionFormatJsonObject(event.data) && event.data['childId'] === child.artifact.header.id)?.data
expect(isSessionFormatJsonObject(catalog), child.path).toBe(true)
const createdAt = (catalog as SessionFormatJsonObject)['childCreatedAt'] as number
if (createdAt !== child.artifact.header.createdAt) conflicts.push(child.path)
if (historicalChildCatalogSource(child.artifact)['descriptorCount'] === 0) withoutDescriptor++
// Only this detached control changes a clock; committed generations are immutable rejection evidence.
return { ...child.artifact, header: { ...child.artifact.header, createdAt } }
})
if (children.some(child => conflicts.includes(child.path))) {
expect(() => migrate(parent.artifact, children.map(child => child.artifact)), parent.path).toThrow('conflicts with its parent catalog')
} else {
expect(migrate(parent.artifact, children.map(child => child.artifact)).events, parent.path).toEqual(parent.artifact.events)
}
expect(migrate(parent.artifact, aligned), parent.path).toEqual({
...parent.artifact, header: { ...parent.artifact.header, version: 4 },
})
}
}
}
expect(pairs).toBe(24)
expect(withoutDescriptor).toBe(1)
expect(conflicts.sort()).toEqual(EXPECTED_CONFLICTS)
})
const EXPECTED_CONFLICTS = [
'snapshots/sdk/subagent-continuable-inheritance/session.1.v3.jsonl',
'snapshots/sdk/subagent-continuable/session.1.v3.jsonl',
'snapshots/sdk/subagent-fork-in-process/session.1.v3.jsonl',
'snapshots/sdk/subagent-list-agents/session.1.v3.jsonl',
'snapshots/sdk/subagent-mixed/session.1.v3.jsonl',
'snapshots/sdk/subagent-mixed/session.2.v3.jsonl',
'snapshots/sdk/subagent-send-message/session.1.v3.jsonl',
'snapshots/sdk/subagent-spawn-in-process/session.1.v3.jsonl',
'snapshots/session/advanced-toolchain-runtime/session.1.v3.jsonl',
'snapshots/session/advanced-toolchain-runtime/session.2.v3.jsonl',
'snapshots/session/advanced-toolchain/session.1.v3.jsonl',
'snapshots/session/advanced-toolchain/session.2.v3.jsonl',
'snapshots/session/ralph-loop/session.1.v3.jsonl',
'snapshots/session/ralph-loop/session.2.v3.jsonl',
'snapshots/session/subagent-child-question-rejection/session.1.v3.jsonl',
'snapshots/session/subagent-depth-two-rejection/session.1.v3.jsonl',
'snapshots/session/subagent-depth-two-rejection/session.2.v3.jsonl',
'snapshots/session/subagent-max-tokens-partial/session.1.v3.jsonl',
'snapshots/session/subagent-parallel/session.1.v3.jsonl',
'snapshots/session/subagent-parallel/session.2.v3.jsonl',
'snapshots/session/subagent-published-run-failure/session.1.v3.jsonl',
'snapshots/session/subagent-spawn-in-process/session.1.v3.jsonl',
'snapshots/session/subagent-tool-filter/session.1.v3.jsonl',
'snapshots/session/workflow-run/session.1.v3.jsonl',
]
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest'
import { restoreReleasedV3Artifact } from '@deepseek-ai/dsh-session-format-v2-to-v3'
import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { releasedV3SessionFormatCodec, sessionFormatV3ToV4 } from '../src/index.ts'
import { createSessionFormatCatalogWithChildren, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { releasedV3SessionFormatCodec, createSessionFormatV3ToV4, sessionFormatV3ToV4 } from '../src/index.ts'
const header: SessionFormatHeader = { version: 3, id: 'identity', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const fact: SessionFormatEvent = { type: 'feedback/record', seq: 0, time: 2, data: { text: 'retained' } }
@@ -13,7 +14,7 @@ const delivery = (version: number | undefined, sessionId = header.id): SessionFo
})
function stage(sourceHeader = header, sourceInheritedEventCount?: number) {
return sessionFormatV3ToV4.createStage({
return createSessionFormatV3ToV4([]).createStage({
sourceHeader, targetHeader: sessionFormatV3ToV4.migrateHeader(sourceHeader),
sourceInheritedEventCount, sourceKind: 'decoded',
})
@@ -27,12 +28,12 @@ function migrate(events: readonly SessionFormatEvent[], sourceHeader = header, c
}
function restore(events: readonly SessionFormatEvent[], sourceHeader = header) {
const reader = sessionFormatCatalog.createRestore({ type: 'session', ...sourceHeader }, { recovery: 'strict', validation: 'current' })
const reader = createSessionFormatCatalogWithChildren([]).createRestore({ type: 'session', ...sourceHeader }, { recovery: 'strict', validation: 'current' })
for (const event of events) reader.decodeRow(event)
return reader.finish()
}
describe('V3 to V4 identity migration', () => {
describe('V3 to V4 source preservation', () => {
it('changes only the header version and retains event objects, payloads, timestamps, and coordinates', () => {
const rows = [fact, delivery(3)]
const before = JSON.stringify({ header, rows })
@@ -137,7 +138,7 @@ describe('V3 to V4 identity migration', () => {
? { type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0, parentSession: 'ancestor', seedLength: 6 }
: { type: 'session', ...header, version, isSeeded: true, parentSession: 'ancestor' }
const before = JSON.stringify({ physical, source })
const reader = sessionFormatCatalog.createRestore(physical, { recovery: 'strict', validation: 'current' })
const reader = createSessionFormatCatalogWithChildren([]).createRestore(physical, { recovery: 'strict', validation: 'current' })
for (const row of source) reader.decodeRow(version === 3 ? releasedV3SessionFormatCodec.encodeEvent(row) : row)
const artifact = reader.finish()
expect(artifact.header.version).toBe(4)
@@ -151,4 +152,29 @@ describe('V3 to V4 identity migration', () => {
expect(reopened.finish()).toEqual(artifact)
expect(JSON.stringify({ physical, source })).toBe(before)
})
it.each(['strict', 'recoverable'] as const)('retains delivery inside a nested inherited prefix during %s restoration', (recovery) => {
const physical = { type: 'session', ...header, isSeeded: true, parentSession: 'ancestor' }
const rows = [fact, seed, { ...delivery(3, 'ancestor'), seq: 2 }, { ...seed, seq: 3 }]
const decoder = releasedV3SessionFormatCodec.createDecoder(physical, 'strict')
const source = new SessionFormatEventCollector()
for (const row of rows) decoder.decodeRow(row, source)
const artifact = restoreReleasedV3Artifact({ header: decoder.header, events: source.values,
inheritedEventCount: decoder.finish(source) }, new Set(rows.map(row => row.type)))
expect(artifact.inheritedEventCount).toBe(3)
const reader = createSessionFormatCatalogWithChildren([]).createRestore(physical, { recovery, validation: 'current' })
for (const row of rows) reader.decodeRow(row)
expect(reader.finish()).toEqual({ ...artifact, header: { ...artifact.header, version: 4 } })
})
it.each(['strict', 'recoverable'] as const)('refuses foreign delivery after the final inherited cut during %s restoration', (recovery) => {
const reader = createSessionFormatCatalogWithChildren([]).createRestore({ type: 'session', ...header, isSeeded: true, parentSession: 'ancestor' }, {
recovery, validation: 'current',
})
for (const row of [fact, seed, { ...delivery(3, 'ancestor'), seq: 2 }, { ...seed, seq: 3 }, { ...delivery(3, 'ancestor'), seq: 4 }]) {
reader.decodeRow(row)
}
expect(() => reader.finish()).toThrow('wrong Session')
})
})
@@ -8,7 +8,9 @@ import SessionStore, {
SessionSeq,
type CreateSessionOptions,
type SessionEvent,
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import { createSessionFormatCatalogWithChildren } from '@deepseek-ai/dsh-session-format-catalog'
import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import { createAssistantMessage, createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -215,6 +217,36 @@ describe('incremental DeepSeek session-log upload', () => {
expect(forkPayload.fields.dsh_session_log).toMatchObject({ afterSeq: -1, throughSeq: fork.session.seq - 1 })
})
it('uploads a migrated V3 log from the beginning before resuming current-generation acknowledgements', async () => {
const id = SessionId('migrated-v3-delivery')
const reader = createSessionFormatCatalogWithChildren([]).createRestore({
type: 'session', version: 3, id, createdAt: 1, isSeeded: false, delegationDepth: 0,
}, { recovery: 'strict', validation: 'current' })
reader.decodeRow({ type: 'feedback/record', seq: 0, time: 2, data: { text: 'retained' } })
reader.decodeRow({
type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 3,
data: { sessionId: id, throughSeq: 0, sessionFormatVersion: 3 },
})
const artifact = reader.finish()
const session = Session.fromRestore(id, artifact.events as SessionEvent[],
artifact.header as unknown as SessionHeader, SessionLogOffset(artifact.inheritedEventCount), 'detached')
const { ctx } = await harness('delivery-migration-owner')
ctx.effect(() => ctx.sessions.enter(session))
const first = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: id })
expect(first.fields.dsh_session_log).toMatchObject({ sessionFormatVersion: SESSION_FORMAT_VERSION, afterSeq: -1 })
expect(first.fields.dsh_session_log?.events[0]?.seq).toBe(0)
expect(first.fields.dsh_session_log?.events[1]).toMatchObject({ data: { sessionFormatVersion: 3, throughSeq: 0 } })
const throughSeq = session.seq - 1
await first.accept()
expect(session.snapshotEvents().at(-1)?.data).toEqual({ sessionId: id, sessionFormatVersion: SESSION_FORMAT_VERSION, throughSeq })
const second = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: id })
expect(second.fields.dsh_session_log?.afterSeq).toBe(throughSeq)
expect(second.fields.dsh_session_log?.events.map(event => event.seq)).toEqual([throughSeq + 1])
expect(session.eventAt(SessionSeq(1))?.data).toMatchObject({ sessionFormatVersion: 3, throughSeq: 0 })
})
it('takes the maximum watermark when concurrent acceptances settle out of order', async () => {
const { ctx, session } = await harness('concurrent')
session.append('turn/start', { turn: 1 })
@@ -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/session-persistence-jsonl/README.md
README.md: fc431ff27bcd236c4d44b8d3e7b20d4291b936d4
README.zh.md: 3b265bf43f058d4c0711568df156825febed4d24
README.md: 9b2464e6330cfb5c256f18060814330c7637635e
README.zh.md: 22c0adb2d78efc3fee077ec68f8d43de422aa1a9
@@ -79,7 +79,11 @@ The current-generation scanner applies the current codec owner’s structural ad
### Reading the logs
`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry `sizeBytes` and a best-effort stat-derived revision for the selected file. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Unavailable migration facts report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events.
Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements).
-----
@@ -93,7 +97,7 @@ This section explains the physical encoding and write path; the observable contr
### Design concept
The backend owns its complete storage runtime (`src/storage.ts`): `JsonlSessionHandle` carries the per-handle mutation chain, the routed live-event buffer with its fixed batching window and single-flight drain, monotonic reads, and idempotent close; a tracker holds the in-process single-writer claims, the open-handle set teardown sweeps, and the created-but-unmaterialized pending sessions the backend's own session listeners route into. Historical body reads share one per-session Decode/Migrate preparation, and a bounded revision-keyed memo lets an immediate observe-to-resume handoff reuse that parse; the backend deep-freezes each event graph once before memoization, so later handle reads reuse it without copying or freezing. Only a write open publishes the prepared successor. The package deliberately exposes only its default plugin export plus configuration types — the concrete class is not a named export, so consumers couple to `ctx.sessionPersistence`, and the shared seam suites (`runPersistenceContract`/`runLiveWritePathContract`) pin its observable behavior. Its change token is a best-effort file revision: device, inode, size, and nanosecond timestamps identify one log for `stat`/`list`, for the stable-read loop that retries a read torn by a concurrent append, and for the pre-publication source check.
The backend owns its complete storage runtime (`src/storage.ts`): `JsonlSessionHandle` carries the per-handle mutation chain, the routed live-event buffer with its fixed batching window and single-flight drain, monotonic reads, and idempotent close; a tracker holds the in-process single-writer claims, the open-handle set teardown sweeps, and the created-but-unmaterialized pending sessions the backend's own session listeners route into. Historical body reads share one per-session Decode/Migrate preparation, and a bounded revision-keyed memo lets an immediate observe-to-resume handoff reuse that parse; the backend deep-freezes each event graph once before memoization, so later handle reads reuse it without copying or freezing. Only a write open publishes the prepared successor. The package deliberately exposes only its default plugin export plus configuration types — the concrete class is not a named export, so consumers couple to `ctx.sessionPersistence`, and the shared seam suites (`runPersistenceContract`/`runLiveWritePathContract`) pin its observable behavior. Physical revisions combine device, inode, size, and nanosecond timestamps for the preparation memo, stable-read retries, and publication checks. Historical public revisions add a SHA-256 fingerprint of sorted selected paths and their physical revisions; lock files and retained unselected generations do not contribute.
### Physical encoding
@@ -79,7 +79,11 @@ kind: "package-reference"
### 读取日志
`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的 stat 派生修订号。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。迁移事实不足会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。
历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。
-----
@@ -93,7 +97,7 @@ kind: "package-reference"
### 设计理念
该后端拥有自己完整的存储运行时(`src/storage.ts`):`JsonlSessionHandle` 承载逐句柄修改链、带固定批处理窗口与 single-flight 排空的已路由实时事件缓冲、单调读取与幂等 close;一个 tracker 持有进程内单写者认领、teardown 清扫所遍历的打开句柄集合,以及后端自己的会话监听器所路由进的已创建但未实体化待定会话。历史正文读取共享每个 Session 唯一的一次 Decode/Migrate preparation,按 revision 为键的有界 memo 让紧接的观察到恢复交接复用该解析;backend 在 memo 化前只对每个 event graph 深度冻结一次,因此后续 handle read 无需复制或再次冻结。只有写 open 才发布准备好的后继。本包有意只暴露默认插件导出与配置类型——具体类不是具名导出,因此消费方只耦合 `ctx.sessionPersistence`,其可观察行为由共享 seam 测试套件(`runPersistenceContract`/`runLiveWritePathContract`)钉住。其变更令牌是尽力而为的文件修订值:device、inode、size 与纳秒时间戳标识一份日志,供 `stat`/`list`、在并发 append 撕裂读取时重试的稳定读取循环,以及发布前源检查使用。
该后端拥有自己完整的存储运行时(`src/storage.ts`):`JsonlSessionHandle` 承载逐句柄修改链、带固定批处理窗口与 single-flight 排空的已路由实时事件缓冲、单调读取与幂等 close;一个 tracker 持有进程内单写者认领、teardown 清扫所遍历的打开句柄集合,以及后端自己的会话监听器所路由进的已创建但未实体化待定会话。历史正文读取共享每个 Session 唯一的一次 Decode/Migrate preparation,按 revision 为键的有界 memo 让紧接的观察到恢复交接复用该解析;backend 在 memo 化前只对每个 event graph 深度冻结一次,因此后续 handle read 无需复制或再次冻结。只有写 open 才发布准备好的后继。本包有意只暴露默认插件导出与配置类型——具体类不是具名导出,因此消费方只耦合 `ctx.sessionPersistence`,其可观察行为由共享 seam 测试套件(`runPersistenceContract`/`runLiveWritePathContract`)钉住。物理修订号组合 device、inode、size 与纳秒时间戳,供准备缓存、稳定读取重试及发布检查使用。历史格式的公开修订号还加入所选路径排序后及其物理修订号的 SHA-256 指纹;锁文件与保留但未被选中的代际不参与计算。
### 物理编码
@@ -0,0 +1,82 @@
/** Collect historical discovery facts without recursively preparing related current generations. */
import { stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { historicalSessionFormatCatalog, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { historicalChildCatalogSource } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence'
import { SessionFormatError, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { parseGenerationLogFilename } from './format.ts'
import type { JsonlCompression } from './format.ts'
import { JsonlGenerationSourceChangedError, readDecodedJsonlSource } from './generation.ts'
import type { JsonlPhysicalIdentity } from './generation.ts'
/** Per-parent supplemental facts and the source checks required before serving or publishing them. */
interface PreparedCatalogFacts {
readonly facts: readonly SessionFormatJsonObject[]
/** @returns resolves while every decoded child still has the captured physical revision. */
validate(): Promise<void>
}
/**
* Collect each related child's own descriptor through existing historical codecs.
* @param parentId - parent whose incoming migration consumes these facts.
* @param sources - header-indexed direct children in the selected source corpus.
* @param compression - configured source encoding.
* @param signal - cancellation forwarded through each source read.
* @returns compact facts; complete child event arrays are released after extraction.
*/
export async function prepareCatalogFacts(
parentId: SessionId,
sources: readonly { readonly header: SessionHeader; readonly path: string }[],
compression: JsonlCompression,
signal: AbortSignal,
): Promise<PreparedCatalogFacts> {
const facts: SessionFormatJsonObject[] = []
const witnesses: { path: string; identity: JsonlPhysicalIdentity }[] = []
for (const source of sources) {
signal.throwIfAborted()
const version = parseGenerationLogFilename(basename(source.path), compression)
if (version === undefined) throw new SessionFormatUnsupportedMigrationError(`unrecognized historical child generation ${source.path}`)
let restored: Awaited<ReturnType<typeof readDecodedJsonlSource>>
try {
restored = await readDecodedJsonlSource(source.path, version, compression, {
createRestore: header => (version <= 3 ? historicalSessionFormatCatalog : sessionFormatCatalog).createRestore(header, {
recovery: 'recoverable', validation: 'current',
}),
}, signal)
} catch (error: unknown) {
if (error instanceof SessionFormatUnsupportedMigrationError) {
throw new SessionFormatUnsupportedError(
`child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`,
{ kind: 'jsonl', path: source.path },
)
}
if (error instanceof SessionFormatError) {
throw new SessionPersistenceCorruptionError(`child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, { cause: error })
}
throw error
}
const header = restored.artifact.header
if (header.id !== source.header.id || header.createdAt !== source.header.createdAt
|| header.parentSession !== parentId || header.origin !== 'subagent'
|| ['cwd', 'isSeeded', 'delegationDepth', 'agentPreset'].some(key => header[key] !== source.header[key as keyof SessionHeader])) {
throw new JsonlGenerationSourceChangedError(source.path)
}
facts.push({ ...historicalChildCatalogSource(restored.artifact), sourcePath: source.path })
witnesses.push({ path: source.path, identity: restored.identity })
}
return {
facts,
async validate() {
for (const witness of witnesses) {
const current = await stat(witness.path, { bigint: true })
if (current.dev !== witness.identity.dev || current.ino !== witness.identity.ino
|| current.size !== witness.identity.size || current.mtimeNs !== witness.identity.mtimeNs
|| current.ctimeNs !== witness.identity.ctimeNs) throw new JsonlGenerationSourceChangedError(witness.path)
}
},
}
}
@@ -23,7 +23,7 @@ import { parseSessionFormatLogFilename, sessionFormatLogFilename, SessionFormatU
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatRecovery, SessionFormatRestore } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { assertV4RowAdmission } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { assertV4RowAdmission, assertReleasedV4Relationships } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import {
SessionFormatUnsupportedError,
sessionFormatVersionRefusal,
@@ -464,6 +464,7 @@ export class SessionLogScanner {
finish(): SessionLogScan {
this.finished = true
const artifact = this.restore.finish()
assertReleasedV4Relationships(artifact)
return {
meta: this.meta,
inheritedEventCount: SessionLogOffset(artifact.inheritedEventCount),
@@ -28,6 +28,7 @@ import { constants, createZstdCompress } from 'node:zlib'
import { Session } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import { SessionFormatError } from '@deepseek-ai/dsh-session-format'
import type {
SessionFormatArtifact,
SessionFormatJsonValue,
@@ -68,6 +69,8 @@ export interface JsonlGenerationFormatAdapter {
/** Inputs for preparing one historical generation and publishing its current successor later. */
export interface PrepareJsonlMigrationOptions {
/** Revalidate related source facts before preparation returns and immediately before publication. */
readonly validateRelatedSources?: () => Promise<void>
/** Immutable generation selected by the backend resolver. */
readonly sourcePath: string
/** Version selected from the source filename and independently checked against its header. */
@@ -372,7 +375,7 @@ interface StartedMigrationStream {
async function startMigrationStream(
headerRecord: Buffer,
sourceVersion: number,
format: JsonlGenerationFormatAdapter,
format: Pick<JsonlGenerationFormatAdapter, 'createRestore'>,
validateHistoricalHeader?: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
): Promise<StartedMigrationStream> {
const value = parseJson(headerRecord.subarray(0, -1).toString('utf8'), 'header line')
@@ -409,7 +412,7 @@ async function decodeStreamingMigration(
bytes: Buffer,
compression: JsonlCompression,
sourceVersion: number,
format: JsonlGenerationFormatAdapter,
format: Pick<JsonlGenerationFormatAdapter, 'createRestore'>,
validateHistoricalHeader: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
signal?: AbortSignal,
): Promise<SessionFormatArtifact> {
@@ -900,6 +903,7 @@ async function publishPreparedMigration(
throw new Error('staged session generation changed during verification')
}
await internals.barrier('before-source-check', 1)
await options.validateRelatedSources?.()
const beforePublish = await internals.fs.stat(sourcePath)
if (identity(beforePublish) !== identity(sourceIdentity)) {
throw new JsonlGenerationSourceChangedError(sourcePath)
@@ -974,6 +978,7 @@ async function prepareMigration(
if (artifact.header.version !== format.currentVersion) {
throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`)
}
await options.validateRelatedSources?.()
const sourceIdentity = source.identity
let publication: Promise<JsonlPhysicalIdentity> | undefined
return {
@@ -1024,3 +1029,31 @@ export function createJsonlGenerationRuntime(
}
const defaultGenerationRuntime = createJsonlGenerationRuntime()
/**
* Read one stable source through the shared streaming parser without publishing a generation.
* @param path - selected source generation path.
* @param version - physical source version identified by its filename.
* @param compression - source encoding.
* @param format - codec/restore factory, independent of current-generation publication.
* @param signal - cancellation observed during source reads and decode yields.
* @returns decoded artifact and physical source identity for later revalidation.
* @throws SessionFormatError for physical decoding failures; storage, cancellation, and unsupported migration errors retain their category.
*/
export async function readDecodedJsonlSource(
path: string,
version: number,
compression: JsonlCompression,
format: Pick<JsonlGenerationFormatAdapter, 'createRestore'>,
signal?: AbortSignal,
): Promise<{ artifact: SessionFormatArtifact; identity: JsonlPhysicalIdentity }> {
const source = await readStableJsonlFile(path, signal)
let artifact: SessionFormatArtifact
try {
artifact = await decodeStreamingMigration(source.bytes, compression, version, format, undefined, signal)
} catch (error: unknown) {
if (signal?.aborted || error instanceof SessionFormatError) throw error
throw new SessionFormatError(String(error), { cause: error })
}
return { artifact, identity: source.identity }
}
@@ -9,6 +9,7 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import {
createSessionFormatCatalogWithChildren,
SessionFormatUnsupportedMigrationError,
sessionFormatCatalog,
} from '@deepseek-ai/dsh-session-format-catalog'
@@ -17,7 +18,7 @@ import { open, mkdir, readdir, realpath, link, rm, stat, truncate } from 'node:f
import { dirname, join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { scheduler } from 'node:timers/promises'
import { randomBytes } from 'node:crypto'
import { createHash, randomBytes } from 'node:crypto'
import {
SessionPersistence, SessionPersistenceRevision, SessionFormatUnsupportedError,
SessionPersistenceCorruptionError,
@@ -44,6 +45,7 @@ import {
} from './zstd.ts'
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
import { verifyCurrentGenerationInWorker } from './migration-verifier.ts'
import { prepareCatalogFacts } from './catalog-migration.ts'
import {
JsonlGenerationSourceChangedError,
JsonlGenerationUnsupportedMigrationError,
@@ -122,6 +124,7 @@ interface CurrentStoredLog extends StoredLogBase {
/** A migrated historical generation retained until an explicit write open publishes it. */
interface PreparedStoredLog extends StoredLogBase {
readonly status: 'prepared'
readonly validateRelatedSources: () => Promise<void>
readonly publication: {
readonly source: ResolvedJsonlGeneration
readonly value: PreparedJsonlMigration
@@ -245,7 +248,7 @@ class JsonlSessionPersistence extends SessionPersistence {
private compression: JsonlCompression
private rootEncodingCheck: Promise<void> | undefined
private readonly tracker = new JsonlBackendTracker(this.name)
private readonly generationFormat: JsonlGenerationFormatAdapter
private readonly generationFormat: Omit<JsonlGenerationFormatAdapter, 'createRestore'>
/**
* Bounded LRU of parsed, validated stored logs keyed by session id and
* guarded by the stat-derived revision, so an immediate cold-read handoff
@@ -271,10 +274,6 @@ class JsonlSessionPersistence extends SessionPersistence {
this.compression = config.compression ?? DEFAULT_COMPRESSION
this.generationFormat = {
currentVersion: sessionFormatCatalog.currentVersion,
createRestore: header => sessionFormatCatalog.createRestore(header, {
recovery: 'recoverable',
validation: 'transformed',
}),
encodeHeader: (header, inheritedEventCount) =>
sessionFormatCatalog.encodeCurrentHeader(header, inheritedEventCount),
encodeEvent: event => sessionFormatCatalog.encodeCurrentEvent(event),
@@ -342,7 +341,13 @@ class JsonlSessionPersistence extends SessionPersistence {
if (pending !== undefined) {
return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, 'read', { cursor: 0, materialized: false, inheritedEventCount: pending.inheritedEventCount }))
}
const stored = await this.requireStoredLog(id, options?.signal)
let stored: StoredLog
try {
stored = await this.requireStoredLog(id, options?.signal)
} catch (error: unknown) {
if (!(error instanceof JsonlGenerationSourceChangedError)) throw error
stored = await this.requireStoredLog(id, options?.signal)
}
let state: StorageHandleState
if (stored.status === 'prepared') {
state = {
@@ -442,7 +447,9 @@ class JsonlSessionPersistence extends SessionPersistence {
options?.signal?.throwIfAborted()
return {
header,
revision: fileRevision(identity),
revision: selected.sourceVersion < SESSION_FORMAT_VERSION
? SessionPersistenceRevision(`${fileRevision(identity)}:${await this.historicalCorpusRevision(options?.signal)}`)
: fileRevision(identity),
sizeBytes: Number(identity.size),
}
} catch (error: unknown) {
@@ -466,7 +473,10 @@ class JsonlSessionPersistence extends SessionPersistence {
// append lands mid-scan is then still in this snapshot (its artifact may
// predate the scan), so create-to-list visibility never has a hole.
const pending = [...this.tracker.pendingEntries()]
for (const artifact of await this.listArtifacts(signal)) {
const artifacts = await this.listArtifacts(signal)
const corpusRevision = artifacts.some(artifact => artifact.sourceVersion < SESSION_FORMAT_VERSION)
? await this.historicalCorpusRevision(signal) : undefined
for (const artifact of artifacts) {
signal?.throwIfAborted()
try {
const identity = await stat(artifact.path, { bigint: true })
@@ -474,7 +484,9 @@ class JsonlSessionPersistence extends SessionPersistence {
listed.add(artifact.header.id)
snapshots.push({
header: artifact.header,
revision: fileRevision(identity),
revision: artifact.sourceVersion < SESSION_FORMAT_VERSION
? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`)
: fileRevision(identity),
sizeBytes: Number(identity.size),
})
} catch (error: unknown) {
@@ -567,6 +579,12 @@ class JsonlSessionPersistence extends SessionPersistence {
signal.throwIfAborted()
const memoized = this.coldLogMemo.get(id)
if (memoized?.status === 'prepared' && memoized.revision === sourceRevision) {
try {
await memoized.validateRelatedSources()
} catch (error: unknown) {
this.coldLogMemo.delete(id)
throw this.generationFailure(id, selected, error)
}
this.coldLogMemo.delete(id)
this.coldLogMemo.set(id, memoized)
return memoized
@@ -602,13 +620,33 @@ class JsonlSessionPersistence extends SessionPersistence {
signal: AbortSignal,
): Promise<PreparedStoredLog> {
let prepared: Awaited<ReturnType<typeof prepareJsonlMigration>>
let validateRelatedSources: () => Promise<void>
try {
const children = async () => (await this.listArtifacts(signal, selected.sourcePath))
.filter(source => source.header.origin === 'subagent' && source.header.parentSession === id)
const sources = await children()
const related = await prepareCatalogFacts(id, sources, this.compression, signal)
const membership = sources.map(source => source.path).sort()
validateRelatedSources = async () => {
const current = (await children()).map(source => source.path).sort()
const before = new Set(membership)
const after = new Set(current)
const changed = current.find(path => !before.has(path)) ?? membership.find(path => !after.has(path))
if (changed !== undefined) throw new JsonlGenerationSourceChangedError(changed)
await related.validate()
}
prepared = await prepareJsonlMigration({
sourcePath: selected.sourcePath,
sourceVersion: selected.sourceVersion,
currentPath: selected.currentPath,
compression: this.compression,
format: this.generationFormat,
format: {
...this.generationFormat,
createRestore: header => createSessionFormatCatalogWithChildren(related.facts).createRestore(header, {
recovery: 'recoverable', validation: 'transformed',
}),
},
validateRelatedSources,
verifyCurrentFile: verifyCurrentGenerationInWorker,
validateHistoricalHeader: headerValue => this.validateSourceIdentity(
selected,
@@ -627,6 +665,7 @@ class JsonlSessionPersistence extends SessionPersistence {
validateStoredEvents(meta, events, { kind: 'jsonl', path: selected.sourcePath })
const stored: PreparedStoredLog = {
status: 'prepared',
validateRelatedSources,
meta,
...freezeStoredEvents(events),
tornTruncateTo: undefined,
@@ -972,35 +1011,77 @@ class JsonlSessionPersistence extends SessionPersistence {
}
}
private async listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>> {
signal?.throwIfAborted()
await this.ensureRootEncoding()
signal?.throwIfAborted()
const artifacts: Array<{ header: SessionHeader; path: string }> = []
const ids = new Set<SessionId>()
/** Enumerate selected physical generations without interpreting their headers or bodies. */
private async listGenerations(signal?: AbortSignal): Promise<ResolvedJsonlGeneration[]> {
const sources: ResolvedJsonlGeneration[] = []
for (const project of await this.listProjectDirs(signal)) {
signal?.throwIfAborted()
for (const dir of await this.listSessionDirs(project, signal)) {
signal?.throwIfAborted()
const selected = await this.resolveGenerationInDirectory(dir, signal)
if (selected === undefined) continue
let header: SessionHeader | undefined
try {
header = await this.readGenerationHeader(selected, undefined, signal)
} catch (error: unknown) {
// Listing skips a foreign format while opening its id still refuses
// with the selected physical location.
if (error instanceof SessionFormatUnsupportedError) continue
throw error
}
if (header === undefined) continue
if (ids.has(header.id)) {
throw new Error(`duplicate JSONL session id "${header.id}" appears in multiple project directories`)
}
ids.add(header.id)
artifacts.push({ header, path: selected.sourcePath })
if (selected !== undefined) sources.push(selected)
}
}
return sources
}
/** Historical logical events depend on the corpus, including members with unreadable headers. */
private async historicalCorpusRevision(signal?: AbortSignal): Promise<string> {
const paths = (await this.listGenerations(signal)).map(source => source.sourcePath).sort()
const hash = createHash('sha256')
for (const path of paths) {
signal?.throwIfAborted()
let revision: string
try {
revision = fileRevision(await stat(path, { bigint: true }))
} catch (error: unknown) {
if (!isENOENT(error)) throw error
revision = 'missing'
}
hash.update(JSON.stringify([path, revision]))
}
signal?.throwIfAborted()
return hash.digest('hex')
}
private async listArtifacts(
signal?: AbortSignal,
migrationSource?: string,
): Promise<Array<{ header: SessionHeader; path: string; sourceVersion: number }>> {
signal?.throwIfAborted()
await this.ensureRootEncoding()
signal?.throwIfAborted()
const artifacts: Array<{ header: SessionHeader; path: string; sourceVersion: number }> = []
const ids = new Set<SessionId>()
for (const selected of await this.listGenerations(signal)) {
signal?.throwIfAborted()
if (migrationSource !== undefined && selected.sourceVersion > SESSION_FORMAT_VERSION) {
throw new SessionFormatUnsupportedError(
`catalog migration cannot inspect Session format v${selected.sourceVersion}; upgrade the harness (raw log: ${selected.sourcePath})`,
{ kind: 'jsonl', path: selected.sourcePath },
)
}
let header: SessionHeader | undefined
try {
header = await this.readGenerationHeader(selected, undefined, signal)
} catch (error: unknown) {
if (error instanceof SessionFormatUnsupportedError && migrationSource === undefined) continue
throw error
}
if (header === undefined) {
if (migrationSource !== undefined && selected.sourcePath !== migrationSource) {
throw new SessionFormatUnsupportedError(
`cannot establish complete catalog membership from an unreadable Session header (raw log: ${selected.sourcePath})`,
{ kind: 'jsonl', path: selected.sourcePath },
)
}
continue
}
if (ids.has(header.id)) {
throw new Error(`duplicate JSONL session id "${header.id}" appears in multiple project directories`)
}
ids.add(header.id)
artifacts.push({ header, path: selected.sourcePath, sourceVersion: selected.sourceVersion })
}
signal?.throwIfAborted()
return artifacts
}
@@ -0,0 +1,77 @@
/** Threshold-free, built-runtime measurements of historical catalog reads as the corpus grows. */
import { Context } from '@deepseek-ai/cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { spawnSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const CHILDREN = 4
const CHILD_EVENTS = 1_000
const SAMPLES = 3
async function measure(unrelated: number) {
const root = await mkdtemp(join(tmpdir(), 'dsh-catalog-perf-'))
const ctx = new Context()
try {
for (let i = 0; i < unrelated + CHILDREN + 1; i++) {
const id = i === 0 ? 'parent' : `session-${i}`
const child = i > 0 && i <= CHILDREN
const directory = join(root, '_no-cwd', id)
await mkdir(directory, { recursive: true })
const header = { type: 'session', version: 3, id, createdAt: i + 1, isSeeded: false, delegationDepth: child ? 1 : 0,
...(child ? { origin: 'subagent', parentSession: 'parent' } : {}) }
const events = child ? [
{ type: 'subagent/descriptor', seq: 0, time: 1, data: { version: 3, mode: 'one-shot', provider: 'spawn' } },
...Array.from({ length: CHILD_EVENTS - 1 }, (_, seq) => ({
type: 'feedback/record', seq: seq + 1, time: 2, data: { text: 'Synthetic migration observation. '.repeat(4) },
})),
] : []
await writeFile(join(directory, 'session.v3.jsonl'), [header, ...events].map(row => JSON.stringify(row) + '\n').join(''))
}
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
const parent = SessionId('parent')
const timed = async (operation: () => Promise<unknown>): Promise<number> => {
const start = performance.now()
await operation()
return performance.now() - start
}
const read = async (): Promise<void> => {
const handle = await ctx.sessionPersistence.open(parent, 'read')
try { await handle.read() } finally { await handle.close() }
}
const statMs = await timed(() => ctx.sessionPersistence.stat(parent))
const coldReadMs = await timed(read)
const memoReadMs = await timed(read)
const publishMs = await timed(async () => {
const handle = await ctx.sessionPersistence.open(parent, 'write')
await handle.close()
})
const currentReadMs = await timed(read)
return { unrelated, children: CHILDREN, childEvents: CHILD_EVENTS, statMs, coldReadMs, memoReadMs, publishMs, currentReadMs }
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
}
}
if (process.argv[2] === '--sample') {
process.stdout.write(JSON.stringify(await measure(Number(process.argv[3]))) + '\n')
} else {
const samples = []
for (const size of [0, 100, 1_000]) {
for (let sample = 0; sample < SAMPLES; sample++) {
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--sample', String(size)], {
encoding: 'utf8', timeout: 60_000,
})
if (result.error !== undefined) throw result.error
if (result.signal !== null || result.status !== 0) throw new Error(`measurement failed: ${result.signal ?? result.status}: ${result.stderr}`)
samples.push(JSON.parse(result.stdout) as Record<string, number>)
}
}
process.stdout.write(JSON.stringify({ node: process.version, platform: process.platform, arch: process.arch, samples }, null, 2) + '\n')
}
@@ -0,0 +1,364 @@
import { Context } from '@deepseek-ai/cordis'
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { afterEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { generationLogPath } from '../src/format.ts'
import { prepareCatalogFacts } from '../src/catalog-migration.ts'
import { compressZstdFrame } from '../src/zstd.ts'
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', (compression) => {
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-catalog-migration-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(JsonlSessionPersistence, { root, compression })
const parent = SessionId('parent')
const header = { type: 'session', version: 3, id: parent, createdAt: 1, isSeeded: false, delegationDepth: 0 }
async function write(id: string, events: unknown[], child = false, version = 3) {
const path = generationLogPath(root, undefined, SessionId(id), version, compression)
await mkdir(dirname(path), { recursive: true })
const meta: Record<string, unknown> = { ...header, id, version, ...(child ? { origin: 'subagent', parentSession: parent, createdAt: 2, delegationDepth: 1 } : {}) }
if (version < 2) delete meta['isSeeded']
const first = JSON.stringify(meta) + '\n'
const body = events.map(row => JSON.stringify(row) + '\n').join('')
await writeFile(path, compression === 'none' ? first + body
: Buffer.concat([await compressZstdFrame(first), ...(body.length === 0 ? [] : [await compressZstdFrame(body)])]))
return path
}
await write(parent, [])
const descriptor = { type: 'subagent/descriptor', seq: 0, time: 2, data: { version: 3, mode: 'continuable', provider: 'spawn', label: 'old child' } }
async function read() {
const handle = await ctx.sessionPersistence.open(parent, 'read')
try { return (await handle.read()).events } finally { await handle.close() }
}
return { root, ctx, parent, header, descriptor, write, read }
}
it.each(['read', 'write'] as const)('refuses foreign native V4 delivery through %s access', async (access) => {
const f = await fixture()
await f.write(f.parent, [
{ type: 'feedback/record', seq: 0, time: 1, data: {} },
{ type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
data: { sessionId: 'other', sessionFormatVersion: 4, throughSeq: 0 } },
], false, 4)
await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('wrong Session')
})
it('does not publish incomplete membership after a child header becomes unreadable', async () => {
const f = await fixture()
const path = await f.write('child', [], true)
const broken = '{broken header\n'
await writeFile(path, compression === 'none' ? broken : await compressZstdFrame(broken))
await expect(f.read()).rejects.toThrow(path)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(path)
expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it('invalidates lightweight revisions on child membership and contents while retaining stable tokens', async () => {
const f = await fixture()
const revision = async () => (await f.ctx.sessionPersistence.stat(f.parent))!.revision
const first = await revision()
expect(await revision()).toBe(first)
await f.write('child', [f.descriptor], true)
const added = await revision()
expect(added).not.toBe(first)
expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).toBe(added)
await f.write('child', [f.descriptor, { type: 'feedback/record', seq: 1, time: 3, data: {} }], true)
expect(await revision()).not.toBe(added)
})
it('reports malformed native catalog data without migration terminology', async () => {
const f = await fixture()
await f.write(f.parent, [{ type: 'subagent/catalog', seq: 0, time: 1,
data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'invalid' } }], false, 4)
const error = await f.read().catch((error: unknown) => error)
expect(error).toBeInstanceOf(SessionPersistenceCorruptionError)
expect((error as Error).message).not.toContain('migration requires')
})
it('keeps current revisions independent of unrelated historical logs and write ownership', async () => {
const f = await fixture()
await f.write(f.parent, [], false, 4)
const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
await f.write('child', [f.descriptor], true)
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).toBe(first)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
})
it('invalidates historical revisions when selected children disappear or gain an opaque successor', async () => {
const f = await fixture()
const path = await f.write('child', [f.descriptor], true)
const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
await rm(path)
const removed = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
expect(removed).not.toBe(first)
await f.write('child', [], true, 5)
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).not.toBe(removed)
expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).not.toBe(removed)
})
it('reports a corrupt child body at the child location', async () => {
const f = await fixture()
const catalog = { type: 'subagent/catalog', seq: 0, time: 1,
data: { version: 0, childId: 'grandchild', childCreatedAt: 3, mode: 'one-shot' } }
const path = await f.write('child', [catalog, { ...catalog, seq: 1 }], true, 4)
await expect(f.read()).rejects.toBeInstanceOf(SessionPersistenceCorruptionError)
await expect(f.read()).rejects.toThrow(path)
})
it.each(['read', 'write'] as const)('locates physical child JSON corruption during %s access', async (access) => {
const f = await fixture()
const childPath = await f.write('child', [], true)
const first = JSON.stringify({ ...f.header, id: 'child', origin: 'subagent', parentSession: f.parent, delegationDepth: 1 }) + '\n'
// A sealed turn makes the malformed row corruption rather than a recoverable tail.
const body = '{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'completed' } } }) + '\n'
await writeFile(childPath, compression === 'none' ? first + body
: Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)]))
const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression)
const original = await Promise.all([readFile(parentPath), readFile(childPath)])
const error: unknown = await f.ctx.sessionPersistence.open(f.parent, access).catch((error: unknown) => error)
expect(error).toBeInstanceOf(SessionPersistenceCorruptionError)
expect((error as Error).message).toContain(childPath)
expect((error as Error).message).not.toContain(parentPath)
expect((error as Error).message).toContain('row 1 is not valid JSON')
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original)
expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it('preserves opaque inherited catalogs through read preparation and write publication', async () => {
const f = await fixture()
const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression)
const first = JSON.stringify({ ...f.header, isSeeded: true, parentSession: 'ancestor' }) + '\n'
const events = [
{ type: 'subagent/catalog', seq: 0, time: 1, data: { version: 99 } },
{ type: 'session/end-seed', seq: 1, time: 2, data: { inherited: true } },
{ type: 'subagent/catalog', seq: 2, time: 3, data: null },
{ type: 'session/end-seed', seq: 3, time: 4, data: { inherited: true } },
]
const body = events.map(event => JSON.stringify(event) + '\n').join('')
await writeFile(parentPath, compression === 'none' ? first + body
: Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)]))
const original = await readFile(parentPath)
expect(await f.read()).toEqual(events)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
try { expect((await writer.read()).events).toEqual(events) } finally { await writer.close() }
expect(await f.read()).toEqual(events)
expect(await readFile(parentPath)).toEqual(original)
})
it.skipIf(compression !== 'zstd')('locates invalid compressed child frames at the child path', async () => {
const f = await fixture()
const childPath = await f.write('child', [], true)
await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)]))
const error: unknown = await f.read().catch((error: unknown) => error)
expect(error).toBeInstanceOf(SessionPersistenceCorruptionError)
expect((error as Error).message).toContain(childPath)
})
it('keeps a complete parent catalog when a published child never writes a descriptor', async () => {
const f = await fixture()
const catalog = { type: 'subagent/catalog', seq: 0, time: 2,
data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot', label: 'failed startup' } }
const parentPath = await f.write(f.parent, [catalog])
const childPath = await f.write('child', [], true)
const original = await Promise.all([readFile(parentPath), readFile(childPath)])
expect(await f.read()).toEqual([catalog])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
expect(await f.read()).toEqual([catalog])
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original)
})
it.each([1, 2, 3])('backfills a missing catalog from descriptor v%i', async (version) => {
const f = await fixture()
const data = version === 1 ? { version, provider: 'spawn', label: 'old child' }
: { ...f.descriptor.data, version }
await f.write('child', [{ ...f.descriptor, data }], true)
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: {
childId: 'child', childCreatedAt: 2, mode: 'continuable', label: 'old child',
} }])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it('classifies missing discovery information as unsupported migration', async () => {
const f = await fixture()
await f.write('child', [], true)
await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it.each([false, true])('refuses an opaque future generation before publishing membership (prepared=%s)', async (prepared) => {
const f = await fixture()
await f.write('child', [f.descriptor], true)
if (prepared) expect(await f.read()).toHaveLength(1)
await f.write('child', [], true, 5)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it('requires a readable corpus even when an unknown header cannot identify its parent', async () => {
const f = await fixture()
await f.write('future-root', [], false, 5)
expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id)).toEqual([f.parent])
await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
})
it('preserves unsupported child decoding errors instead of reporting parent corruption', async () => {
const f = await fixture()
const path = await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true)
await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
await expect(f.read()).rejects.toThrow(path)
})
it('locates incomplete historical descriptor fields at their child source', async () => {
const f = await fixture()
const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true)
await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
await expect(f.read()).rejects.toThrow(path)
})
it.each(['read', 'write'] as const)('rejects duplicate own catalogs through a current %s open', async (access) => {
const f = await fixture()
const catalog = { type: 'subagent/catalog', seq: 0, time: 2,
data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }
await f.write(f.parent, [catalog, { ...catalog, seq: 1 }], false, 4)
await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('duplicate catalog child')
})
it('recollects a changed child within the same read open', async () => {
const f = await fixture()
await f.write('child', [f.descriptor], true)
expect(await f.read()).toHaveLength(1)
await f.write('child', [f.descriptor, { type: 'feedback/record', seq: 1, time: 3, data: { text: 'appended' } }], true)
expect(await f.read()).toHaveLength(1)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it('detects children added after preparation instead of publishing incomplete membership', async () => {
const f = await fixture()
expect(await f.read()).toEqual([])
await f.write('child', [f.descriptor], true)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(generationLogPath(f.root, undefined, SessionId('child'), 3, compression))
expect(await f.read()).toHaveLength(1)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it('refuses an unavailable descriptor without writing a successor', async () => {
const f = await fixture()
await f.write('child', [], true)
await expect(f.read()).rejects.toThrow('supported subagent descriptor')
const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression)
expect(await readdir(dirname(parentPath))).toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it('reads current children without recursive migration and preserves current parent fast reads', async () => {
const f = await fixture()
await f.write('child', [f.descriptor], true, 4)
expect(await f.read()).toHaveLength(1)
await f.write(f.parent, [], false, 4)
await f.write('child', [], true, 4)
expect(await f.read()).toEqual([])
})
it('refuses publication when a related child disappears', async () => {
const f = await fixture()
const path = await f.write('child', [f.descriptor], true)
const header = { ...f.header, id: SessionId('child'), origin: 'subagent', parentSession: f.parent, createdAt: 2, delegationDepth: 1 } as SessionHeader
const prepare = () => prepareCatalogFacts(f.parent, [{ path, header }], compression, new AbortController().signal)
const prepared = await prepare()
await rm(path)
await expect(prepared.validate()).rejects.toMatchObject({ code: 'ENOENT' })
await expect(prepare()).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects changed header identities and invalid source selection', async () => {
const f = await fixture()
const path = await f.write('child', [f.descriptor], true)
const header = { ...f.header, id: SessionId('wrong-child'), origin: 'subagent', parentSession: f.parent } as SessionHeader
const signal = new AbortController().signal
await expect(prepareCatalogFacts(f.parent, [{ path, header }], compression, signal)).rejects.toThrow('changed')
await expect(prepareCatalogFacts(f.parent, [{ path: path + '.foreign', header }], compression, signal)).rejects.toThrow('unrecognized historical child generation')
const controller = new AbortController()
controller.abort(new Error('cancelled child collection'))
await expect(prepareCatalogFacts(f.parent, [{ path, header }], compression, controller.signal)).rejects.toThrow('cancelled child collection')
})
it.each([0, 1, 2])('composes a V%i parent and child through all preceding edges', async (version) => {
const f = await fixture()
const initial = generationLogPath(f.root, undefined, f.parent, 3, compression)
await rm(initial)
const parentPath = await f.write(f.parent, [], false, version)
const childPath = await f.write('child', [f.descriptor], true, version)
const before = await Promise.all([readFile(parentPath), readFile(childPath)])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', seq: 0, data: { childId: 'child', mode: 'continuable', label: 'old child' } }])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(before)
expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')).toHaveLength(2)
})
it('lists historical headers, prepares read-only membership, and publishes an unchanged-prefix successor', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-catalog-migration-'))
roots.push(root)
const parent = SessionId('parent')
const child = SessionId('child')
const header = { type: 'session', version: 3, id: parent, createdAt: 1, isSeeded: false, delegationDepth: 0 }
const childHeader = { ...header, id: child, createdAt: 2, origin: 'subagent', parentSession: parent, delegationDepth: 1 }
const descriptor = { type: 'subagent/descriptor', seq: 0, time: 2, data: { version: 3, mode: 'one-shot', provider: 'spawn', label: 'old child' } }
const sourcePaths: string[] = []
for (const [meta, events] of [[header, []], [childHeader, [descriptor]]] as const) {
const path = generationLogPath(root, undefined, meta.id, 3, compression)
sourcePaths.push(path)
await mkdir(dirname(path), { recursive: true })
const first = Buffer.from(JSON.stringify(meta) + '\n')
const body = Buffer.from(events.map(event => JSON.stringify(event) + '\n').join(''))
await writeFile(path, compression === 'none' ? Buffer.concat([first, body])
: Buffer.concat([await compressZstdFrame(first), ...(body.length === 0 ? [] : [await compressZstdFrame(body)])]))
}
const original = await Promise.all(sourcePaths.map(path => readFile(path)))
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(JsonlSessionPersistence, { root, compression })
expect((await ctx.sessionPersistence.list()).map(row => row.header.id).sort()).toEqual([child, parent])
const reader = await ctx.sessionPersistence.open(parent, 'read')
let expected
try {
expect(reader.header.version).toBe(4)
expected = (await reader.read()).events
expect(expected).toEqual([{ type: 'subagent/catalog', seq: 0, time: 1, data: {
version: 0, childId: child, childCreatedAt: 2, mode: 'one-shot', label: 'old child',
} }])
expect(await readdir(dirname(sourcePaths[0]!))).toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
} finally { await reader.close() }
const writer = await ctx.sessionPersistence.open(parent, 'write')
try { expect((await writer.read()).events).toEqual(expected) } finally { await writer.close() }
expect(await Promise.all(sourcePaths.map(path => readFile(path)))).toEqual(original)
const reopened = await ctx.sessionPersistence.open(parent, 'read')
try { expect((await reopened.read()).events).toEqual(expected) } finally { await reopened.close() }
})
})
@@ -23,6 +23,7 @@ import {
JsonlGenerationTargetConflictError,
JsonlGenerationUnsupportedMigrationError,
prepareJsonlMigration,
readDecodedJsonlSource,
verifyJsonlCurrentGeneration,
type JsonlGenerationFormatAdapter,
type PrepareJsonlMigrationOptions,
@@ -30,7 +31,7 @@ import {
import { createJsonlGenerationTestRuntime } from '../src/testing/generation.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
import type { JsonlCompression } from '../src/format.ts'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { createSessionFormatCatalogWithChildren, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import type {
SessionFormatArtifact,
SessionFormatEvent,
@@ -85,6 +86,20 @@ function header(version: number, id = 'generation-test'): Record<string, unknown
const event0 = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }
const event1 = { type: 'turn/end', seq: 1, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }
it('preserves cancellation raised while creating a child source decoder', async () => {
const path = join(await tempRoot(), 'session.v3.jsonl')
await writeFile(path, line(header(3)))
const controller = new AbortController()
const reason = new Error('child decoding cancelled')
await expect(readDecodedJsonlSource(path, 3, 'none', {
createRestore() {
controller.abort(reason)
throw reason
},
}, controller.signal)).rejects.toBe(reason)
})
const assistantUsage = { inputTokens: 3, outputTokens: 2 }
const assistantReplayState = { response: { id: 'response' } }
@@ -179,7 +194,7 @@ function adapter(overrides: Partial<TestGenerationFormatAdapter> = {}): TestGene
function catalogAdapter(): JsonlGenerationFormatAdapter {
return {
currentVersion: sessionFormatCatalog.currentVersion,
createRestore: header => sessionFormatCatalog.createRestore(header, {
createRestore: header => createSessionFormatCatalogWithChildren([]).createRestore(header, {
recovery: 'recoverable', validation: 'transformed',
}),
encodeHeader: (header, inheritedEventCount) =>
@@ -1165,6 +1165,25 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
.rejects.toThrow(/released v0 physical header lacks required member "type"/)
})
it('tracks a disappearing corpus member and propagates its storage faults in historical revisions', async () => {
const parent = meta('corpus-revision-parent', '/work')
const child = meta('corpus-revision-child', '/work')
for (const header of [parent, child]) {
const path = historicalLogPath(root, header.cwd, header.id)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(releasedV0Header(header))}\n`)
}
const present = await ctx.sessionPersistence.stat(parent.id)
statFailure.path = historicalLogPath(root, child.cwd, child.id)
statFailure.error = Object.assign(new Error('member disappeared'), { code: 'ENOENT' })
const missing = await ctx.sessionPersistence.stat(parent.id)
expect(missing).toBeDefined()
expect(missing?.revision).not.toBe(present?.revision)
expect((await ctx.sessionPersistence.stat(parent.id))?.revision).toBe(missing?.revision)
statFailure.error = Object.assign(new Error('member denied'), { code: 'EACCES' })
await expect(ctx.sessionPersistence.stat(parent.id)).rejects.toBe(statFailure.error)
})
it('surfaces source-read storage faults and aborts unwrapped during migration', async () => {
const header = meta('source-read-fault', '/work')
const sourcePath = historicalLogPath(root, header.cwd, header.id)
@@ -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/test-support/llm-replay/README.md
README.md: 5877db20babb07523e870d6f3041e1c35f076130
README.zh.md: dc2c015ff4f1347da41f49608452702c9c90a67a
README.md: 1124572ce28375a1d67b474c23dbe29000124f01
README.zh.md: ee7f75509687da17867adbb5e414b74b8775addf
@@ -83,6 +83,8 @@ Two failure modes are not reconstructable from a durable Assistant settlement al
- **An unrecorded session makes a call** — replay fails loud and tells you to re-record the scenario.
- **A scripted placeholder matches nothing** — `{{fromRequest:<regex>}}` resolution validates the pattern and the request corpus and fails loud on no match, an invalid pattern, or an unterminated placeholder.
Isolated transcript extraction supplies an explicit empty child-fact set to V3→V4. It retains recorded catalog entries but does not discover related files. Complete historical parent catalog migration belongs to JSONL persistence.
-----
<a id="understand-the-implementation"></a>
@@ -83,6 +83,8 @@ parent agent 委托给进程内 subagent 的场景会为每个 Session 记录一
- **未记录的会话发起调用**——回放会明确报错,并提示你重新录制场景。
- **脚本占位符匹配不到内容**——`{{fromRequest:<regex>}}` 解析会校验模式与请求语料,匹配不到、模式非法或占位符未闭合都会明确报错。
独立转录提取向 V3→V4 显式提供空的子 Session 事实集合;保留已有目录记录,但不发现关联文件。完整的历史父目录迁移由 JSONL 持久化负责。
-----
<a id="understand-the-implementation"></a>
@@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import { SESSION_FORMAT_VERSION, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session'
import {
createSessionFormatCatalogWithChildren,
SessionFormatUnsupportedMigrationError,
sessionFormatCatalog,
} from '@deepseek-ai/dsh-session-format-catalog'
@@ -232,7 +233,7 @@ function parseSessionFixture(text: string): ParsedSessionFixture {
headerLineNumber = lineNumber
sourceHeader = recordValue
try {
restore = sessionFormatCatalog.createRestore(normalizeProjectedHeader(recordValue), {
restore = createSessionFormatCatalogWithChildren([]).createRestore(normalizeProjectedHeader(recordValue), {
recovery: 'strict',
validation: 'current',
})
@@ -240,13 +240,13 @@ describe('fixture format diagnostics', () => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
return {
...actual,
sessionFormatCatalog: {
...actual.sessionFormatCatalog,
createSessionFormatCatalogWithChildren: () => ({
...actual.createSessionFormatCatalogWithChildren([]),
createRestore(): never {
const failure: unknown = 'decoder exploded'
throw failure
},
},
}),
}
})
try {
@@ -266,8 +266,8 @@ describe('fixture format diagnostics', () => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
return {
...actual,
sessionFormatCatalog: {
...actual.sessionFormatCatalog,
createSessionFormatCatalogWithChildren: () => ({
...actual.createSessionFormatCatalogWithChildren([]),
createRestore() {
return {
header: { version: SESSION_FORMAT_VERSION, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
@@ -277,7 +277,7 @@ describe('fixture format diagnostics', () => {
},
}
},
},
}),
}
})
try {
@@ -297,8 +297,8 @@ describe('fixture format diagnostics', () => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
return {
...actual,
sessionFormatCatalog: {
...actual.sessionFormatCatalog,
createSessionFormatCatalogWithChildren: () => ({
...actual.createSessionFormatCatalogWithChildren([]),
createRestore() {
return {
header: { version: SESSION_FORMAT_VERSION, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
@@ -308,7 +308,7 @@ describe('fixture format diagnostics', () => {
},
}
},
},
}),
}
})
try {
@@ -329,8 +329,8 @@ describe('fixture format diagnostics', () => {
let row = 0
return {
...actual,
sessionFormatCatalog: {
...actual.sessionFormatCatalog,
createSessionFormatCatalogWithChildren: () => ({
...actual.createSessionFormatCatalogWithChildren([]),
createRestore() {
return {
header: { version: SESSION_FORMAT_VERSION, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
@@ -341,7 +341,7 @@ describe('fixture format diagnostics', () => {
finish(): never { throw new Error('unexpected finish') },
}
},
},
}),
}
})
try {
@@ -372,8 +372,8 @@ describe('fixture format diagnostics', () => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
return {
...actual,
sessionFormatCatalog: {
...actual.sessionFormatCatalog,
createSessionFormatCatalogWithChildren: () => ({
...actual.createSessionFormatCatalogWithChildren([]),
createRestore() {
return {
header: { version: SESSION_FORMAT_VERSION, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
@@ -381,7 +381,7 @@ describe('fixture format diagnostics', () => {
finish(): never { throw new Error(message) },
}
},
},
}),
}
})
try {
@@ -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/test-support/session-snapshot/README.md
README.md: 14305a6a27de79690537895a632f9796b4b7d17b
README.zh.md: 7e3d8db31f67a670b026bc4e95eb8e481a2f95eb
README.md: 22f091a45d7b445b615a8a6f60b3426500f7f849
README.zh.md: 526de9b371ec4f07761f46d497fed202ff0dfb8d
@@ -76,6 +76,8 @@ Spill scenarios save through the real local provider under a private temporary r
Retained historical scenarios keep their canonical Session files unchanged and selected for replay, with no newer canonical sibling in the pinned directory. Their exact normalized native writer output is recorded separately in `writer.expected.jsonl` for the parent and `writer.<ordinal>.expected.jsonl` for children; these are output oracles, not replay generations. Output oracles may retain an older supported generation: comparison restores them through the strict catalog, while harvested logs must use the checkout writer. Fresh-writer golden comparisons select `nativeWriterOutput` to tokenize only delivery markers qualified by each input’s original writer generation after strict restoration. Other delivery generations and captured-source fields remain numeric; source-versus-migrated-artifact comparisons preserve exact generations by default. Recording and refresh never store these comparison tokens. Retained SDK scenarios use `notifications.current.expected.jsonl` for current protocol output. Comparisons neither project current events back into a historical format nor strip structural differences. Independent migration tests verify the official transform rather than treating native writer layout as its expected event sequence.
Headless/ACP and SDK adapters compare raw catalog child-creation times with harvested child headers before normalization. Refresh preserves the chosen child header time in every matching catalog entry, including inherited copies, so retained header clocks cannot diverge from freshly written catalog clocks. Committed historical inconsistencies remain unchanged migration-refusal evidence; normalizing their clocks does not establish successful migration.
### Record, replay, and refresh
`pnpm run test:snapshot:record` calls the live LLM and writes the harvested current generation under its canonical versioned filename. Record and refresh never rename or delete a completed generation, including generations of a child role absent from a later run; reviewed source-tree curation removes a predecessor only after the same role has a verified current replacement. Scenarios with an explicit `sessionFormat` remain read-only in record mode. `pnpm run test:snapshot:refresh` stays keyless, runs the selected highest replay input, and writes stdout, owned prompt and tool-schema sidecars, and a fresh current-generation comparable Session output; retained historical scenarios write the separate writer-output oracles instead of a canonical current-format replay generation. Each composition owner keeps its replay patch beside its live patch; top-level `snapshots/` owns Session-driven scenarios, while other expected outputs stay beside their owning package. [`dsh-llm-replay`](../llm-replay/README.md) serves the recorded streams selected through `DSH_SNAPSHOT_*` environment values.
@@ -76,6 +76,8 @@ spill 场景通过真实本地提供方保存到私有临时根目录。fixture
保留历史输入的场景保持规范 Session 文件不变,并继续选择它们进行回放;固定历史版本的目录中没有更新的规范同角色文件。其精确的规范化原生 writer 输出单独记录在父会话的 `writer.expected.jsonl` 和子会话的 `writer.<ordinal>.expected.jsonl` 中;这些是输出比较基准,而非 replay 代际。输出比较基准可以保留较旧的受支持代际:比较通过严格 catalog 恢复它们,而收集的日志必须使用当前检出版本的 writer。新 writer 输出的 golden 比较选择 `nativeWriterOutput`,仅在严格恢复后,将版本限定为各输入原始 writer 代际的 delivery 标记转换为 token。其他 delivery 代际与 captured-source 字段保持数值;源文件与已迁移产物的比较默认保留精确代际。record 与 refresh 从不存储这些比较 token。保留历史输入的 SDK 场景使用 `notifications.current.expected.jsonl` 记录当前协议输出。比较既不将当前事件反向投影为历史格式,也不剥除结构差异。独立迁移测试验证正式转换,而不把原生 writer 布局当作其预期事件序列。
Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创建时间与采集到的子 header 比较。刷新会在每条匹配的目录记录中保留选定的子 header 时间,包括继承副本,防止保留的 header 时钟与新写入的目录时钟分歧。已提交的历史不一致数据保持不变,作为迁移拒绝证据;将其时钟规范化不能证明迁移成功。
### 录制、回放与刷新
`pnpm run test:snapshot:record` 调用在线 LLM(大语言模型),并在规范具名版本文件下写入收集到的当前 generation。record 与 refresh 绝不重命名或删除已完成的 generation,即使后续运行不再产生某个 child 角色也一样;受审阅的源树整理只有在同角色存在已验证的当前替代文件后才移除前代。显式声明 `sessionFormat` 的场景在录制模式下保持只读。`pnpm run test:snapshot:refresh` 保持无密钥,运行选定的最高 replay 输入,并写入 stdout、各 pin 自有的提示词与工具 schema 伴随文件,以及新鲜当前 generation 的可比较 Session 输出;保留历史输入的场景写入单独的 writer 输出比较基准,而非规范当前格式 replay 代际。每个组合 owner 把 replay patch 放在 live patch 旁;顶层 `snapshots/` 拥有 Session 驱动场景,其他预期输出留在其 package owner 旁。[`dsh-llm-replay`](../llm-replay/README.zh.md) 提供通过 `DSH_SNAPSHOT_*` 环境值选择的已记录流。
@@ -98,6 +98,7 @@ export {
parseSystemPromptSnapshot,
parseToolSchemasSnapshot,
refreshFixtureReplacements,
reconcileCatalogCreationTimes,
restorePinnedToolSchemas,
stabilizeFixtureMessageIds,
stabilizeRefreshLog,
@@ -837,6 +837,32 @@ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: strin
return replacements
}
/**
* Check raw parent/child clocks before normalization, or preserve their equality during refresh.
* @param logs - one scenario's parent and child logs with shared Session ids.
* @param policy - validate live output, or align refreshed catalogs to the retained child headers.
* @returns logs with only conflicting catalog creation times replaced under preserve-headers.
*/
export function reconcileCatalogCreationTimes(logs: readonly string[], policy: 'validate' | 'preserve-headers'): string[] {
const headers = new Map(logs.map((log) => {
const header = parseJsonlRecords(log)[0]
return [header?.['id'], header] as const
}))
return logs.map(log => log.split('\n').map((line) => {
if (line.length === 0) return line
const record = JSON.parse(line) as Record<string, unknown>
if (record.type !== 'subagent/catalog' || !isRecord(record.data)) return line
const child = headers.get(record.data.childId)
// Partial harvested corpora need not retain every cataloged child log.
if (child === undefined || record.data.childCreatedAt === child.createdAt) return line
if (policy === 'validate') {
throw new Error(`catalog child ${String(record.data.childId)} creation time ${String(record.data.childCreatedAt)} disagrees with child header ${String(child.createdAt)}`)
}
record.data.childCreatedAt = child.createdAt
return JSON.stringify(record)
}).join('\n'))
}
function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Record<string, unknown> | undefined): void {
if (existing === undefined || existing.type !== record.type) return
if (record.type === 'session') {
@@ -1308,6 +1334,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {},
})
reconcileCatalogCreationTimes(result.sessionLogs.map(log => log.content), 'validate')
for (const log of result.sessionLogs) {
expect(unknownToolCallIds(log.content), `session ${log.id}: snapshot scenarios must not accept UNKNOWN_TOOL`)
.toEqual([])
@@ -1361,7 +1388,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
ctx,
))))
: result.sessionLogs.map(log => scrubSessionSnapshot(portableFixture(log.content)))
const outputFixtures = redactSessionSnapshotIds(stabilizeFixtureMessageIds(freshFixtures, existingFixtures))
const outputFixtures = redactSessionSnapshotIds(stabilizeFixtureMessageIds(
reconcileCatalogCreationTimes(freshFixtures, 'preserve-headers'), existingFixtures,
))
await Promise.all(outputFixtures.map((fixture, index) =>
writeFile(join(dir, outputFixtureFiles[index] as string), fixture)))
fixtureFiles = outputFixtureFiles
@@ -35,6 +35,7 @@ import {
parseSystemPromptSnapshot,
parseToolSchemasSnapshot,
refreshFixtureReplacements,
reconcileCatalogCreationTimes,
scenarioSkipped,
restorePinnedToolSchemas,
type SharedSnapshotClaim,
@@ -1569,3 +1570,29 @@ describe('stabilizeRefreshLog', () => {
expect(outputIds).toEqual(freshNames.map(name => ids[name as keyof typeof ids]))
})
})
describe('raw parent and child catalog clocks', () => {
const child = '{"type":"session","id":"child","createdAt":100}\n'
const parent = (time: number) => [
{ type: 'session', id: 'parent', createdAt: 1 },
{ type: 'subagent/catalog', seq: 0, time: 2, data: { version: 0, childId: 'child', childCreatedAt: time, mode: 'one-shot' } },
].map(row => JSON.stringify(row) + '\n').join('')
it('rejects mismatched raw clocks before normalization can erase them', () => {
expect(() => reconcileCatalogCreationTimes([parent(200), child], 'validate')).toThrow('creation time 200 disagrees with child header 100')
expect(reconcileCatalogCreationTimes([parent(100), child], 'validate')).toEqual([parent(100), child])
})
it('keeps retained child clocks in newly generated catalog fixtures', () => {
const output = reconcileCatalogCreationTimes([parent(200), child], 'preserve-headers')
expect(output).toEqual([parent(100), child])
expect(reconcileCatalogCreationTimes(output, 'validate')).toEqual(output)
expect(reconcileCatalogCreationTimes(output, 'preserve-headers')).toEqual(output)
})
it('preserves partial corpora and unrelated rows verbatim', () => {
const partial = parent(200) + '{"type":"feedback/record","data":null}\n'
expect(reconcileCatalogCreationTimes([partial], 'validate')).toEqual([partial])
})
})
+3
View File
@@ -6501,6 +6501,9 @@ importers:
'@deepseek-ai/dsh-session-format-catalog':
specifier: workspace:^
version: link:../../session/session-format-catalog
'@deepseek-ai/dsh-session-format-v3-to-v4':
specifier: workspace:^
version: link:../../session/session-format-v3-to-v4
js-yaml:
specifier: ^4.2.0
version: 4.3.1
+7 -3
View File
@@ -203,11 +203,12 @@ export function renderSessionFormatCatalog(
'',
"import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'",
"import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'",
"import type { SessionFormatCatalogOptions } from '@deepseek-ai/dsh-session-format'",
"import { validateInstalledCurrentSessionArtifact, validateInstalledCurrentSessionHeader } from './current.ts'",
...imports,
'',
'/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */',
'export const sessionFormatCatalog = createSessionFormatCatalog({',
'/** Static assembly shared by current reads and parent-specific historical restoration. */',
'export const sessionFormatCatalogOptions: SessionFormatCatalogOptions = {',
` currentVersion: ${currentVersion},`,
' codecs: [',
...codecs.map(codec => ` ${codec},`),
@@ -229,7 +230,10 @@ export function renderSessionFormatCatalog(
' validateInstalledCurrentSessionHeader(header)',
' return header',
' },',
'})',
'}',
'',
'/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */',
'export const sessionFormatCatalog = createSessionFormatCatalog(sessionFormatCatalogOptions)',
'',
].join('\n')
}
+3 -1
View File
@@ -31,6 +31,7 @@ import {
parseToolSchemasSnapshot,
redactSessionSnapshotIds,
refreshFixtureReplacements,
reconcileCatalogCreationTimes,
restorePinnedToolSchemas,
scrubModelRequestBulk,
scrubSessionSnapshot,
@@ -822,6 +823,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
recording ? logs.length : files.length,
assertions.dshSdkChild !== undefined,
)
reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate')
const actualContext = contextOf(ordered, cwd)
if (scenario.name === 'tool-error-details') {
const events = results.flatMap(result => result.events)
@@ -873,7 +875,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
stabilizeRefreshLog(log.content, existing, replacements, actualContext),
))
})
expectedContents = redactSessionSnapshotIds(stabilizeFixtureMessageIds(refreshed, expectedContents))
expectedContents = redactSessionSnapshotIds(stabilizeFixtureMessageIds(reconcileCatalogCreationTimes(refreshed, 'preserve-headers'), expectedContents))
}
if (writesSessionFixtures || refreshing && retained) {