From cc9ab200c77ba2b4077c6b514a9f019adb358c7e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 17:40:26 +0800 Subject: [PATCH 01/15] feat(session): add format migration decoder pipeline --- .../2026-06-14-session-persistence.i18n.yaml | 4 +- .../2026-06-14-session-persistence.md | 2 +- .../2026-06-14-session-persistence.zh.md | 2 +- ...10-session-log-version-mechanism.i18n.yaml | 4 +- ...026-08-10-session-log-version-mechanism.md | 15 +- ...-08-10-session-log-version-mechanism.zh.md | 15 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 +- .../session-persistence-jsonl/src/format.ts | 71 +- .../session-persistence-jsonl/src/index.ts | 318 ++++++- .../session-persistence-jsonl/src/win32.ts | 14 + .../tests/jsonl.spec.ts | 352 ++++++- .../tests/win32.spec.ts | 36 + .../tests/zstd.spec.ts | 52 +- .../session-persistence-sqlite/src/index.ts | 208 +++- .../session-persistence-sqlite/src/schema.ts | 22 +- .../tests/sqlite.spec.ts | 215 ++++- .../session-persistence/src/coordinator.ts | 674 +++---------- .../session-persistence/src/format-decoder.ts | 480 ++++++++++ .../src/format-migrations/index.ts | 6 + .../src/format-v0-compat.ts | 293 ++++++ .../session/session-persistence/src/index.ts | 21 +- .../session-persistence/src/revision.ts | 9 + .../tests/format-decoder.spec.ts | 889 ++++++++++++++++++ .../tests/persistence.spec.ts | 367 +++++++- 27 files changed, 3406 insertions(+), 697 deletions(-) create mode 100644 packages/session/session-persistence/src/format-decoder.ts create mode 100644 packages/session/session-persistence/src/format-migrations/index.ts create mode 100644 packages/session/session-persistence/src/format-v0-compat.ts create mode 100644 packages/session/session-persistence/tests/format-decoder.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 61f9754431..014ea6dd12 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md -2026-06-14-session-persistence.md: 62228bd2f5b25b13880a563818d08f3a2d52d956 -2026-06-14-session-persistence.zh.md: b10ceebd95d4d05ae3f7ea620183ad8fffd074ee +2026-06-14-session-persistence.md: cef11271f26c304ad484d7851801bc69d0c1dfda +2026-06-14-session-persistence.zh.md: ecc85439bb47d6c1fb3280f9c9716d29f3684aea diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 62228bd2f5..cef11271f2 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -29,7 +29,7 @@ Key durable, contested choices: Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there. +Format versioning: the header carries a `version`; cold reads accept the current version or a complete static adjacent-version decoder path and reject future versions or missing steps. The format decoder owns historical header and event conversion, while the Coordinator owns operation-specific recovery after decoding ([Session log versioning](2026-08-10-session-log-version-mechanism.md)). The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise. Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index b10ceebd95..ecc85439bb 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -29,7 +29,7 @@ Status: implemented 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取接受当前版本或完整的静态相邻版本 decoder 路径,并拒绝未来版本或缺失步骤。Format decoder 负责历史 header 和 event 转换,Coordinator 只在解码后负责各操作自己的 recovery([Session log 版本机制](2026-08-10-session-log-version-mechanism.md))。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index fa18284fad..2d9bfcd187 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 25eb1230a254219c827b1d2750dba367b113f9f7 -2026-08-10-session-log-version-mechanism.zh.md: ac1088527ba14a8018c5a7fb3c77c9232bd3b3a9 +2026-08-10-session-log-version-mechanism.md: eb9fcef3fa677ee3912c2cbd611601b6b6f83db1 +2026-08-10-session-log-version-mechanism.zh.md: aa257482869a2b18bd0d3fcb2bc883a8cf506dcf diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 25eb1230a2..eb9fcef3fa 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -14,13 +14,21 @@ Session logs must be upgradable after release, and the runtime that ships first **The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers. -**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing. +**Read rules by direction.** Equal version: decode normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: require a complete chain of static n→n+1 `SessionFormatStep`s; a missing step refuses the read and names the gap. The registry is part of the build rather than Cordis composition, so one build has the same durable read capability under every plugin set. + +**Format migration is the decoder, not a Coordinator repair branch.** Backends expose parsed durable data as `unknown` through a repeatable `StoredSessionSource`: one raw header, one exact revision, and `readEvents()` factories that create independently consumable `AsyncIterable` streams bound to that revision. Each step implements `migrateHeader()` and lazy `migrateEvents()` transforms, must advance exactly one version, and may not change the session id or cwd. Any version conversion reads the complete event stream and applies the requested suffix only after all steps; an equal-version read retains backend suffix seek. The decoder validates each output header version, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain. + +**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version steps. + +**Recovery and writeback consume current-format data.** `inspect()` and `readFrom()` decode only in memory. Cold `prepare()`/`load()` first decode the whole source, add the current recovery closers, and replace the exact old revision with that complete balanced current-format stream. Live HMR adoption uses the same replacement primitive after seed verification but does not synthesize closers for a turn still owned by the live Session. A successful replacement or revision conflict discards the prepared object and reopens the stored source before continuing. + +**Replacement is an internal backend compare-and-swap.** `replaceStored(expectedRevision, meta, events)` accepts a streaming current-format log and checks storage identity plus the source revision inside the commit exclusion. JSONL writes and fsyncs a sibling temporary artifact, rechecks the revision while holding its cross-process lock, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX. SQLite stages the event iterator, then rechecks and replaces the header and event rows in one transaction. A failed commit leaves one complete old or new log; retaining a permanent pre-upgrade copy is a separate recovery policy, not part of the format migration API. **A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). ## Consequences -What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. +Format v0 carries direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema; and the static streaming migration decoder with an empty adjacent-version registry. `SESSION_FORMAT_VERSION` remains 0 until a real v0→v1 step lands. The decoder and backend replacement APIs therefore have direct tests without manufacturing a format bump. Writers do not yet set `ignorable` because no producer needs it. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers; the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header fields or decoding any event record, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. ## Alternatives considered @@ -28,3 +36,6 @@ What shipped in v0 (release 0812): direction-aware refusal with the raw-log path - **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption. - **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked. - **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists. +- **Materializing migrations as header and event arrays** — makes the framework proportional to complete log size in memory even when each transformation is record-local. Repeatable revision-bound readers plus iterator transforms preserve retry semantics without imposing that allocation. +- **Version-specific conversion in `PersistenceCoordinator`** — mixes format decoding with operation-specific crash recovery and duplicates behavior across inspect, suffix read, cold continuation, and live adoption. The shared decoder produces only current-format data; each consumer retains its own recovery intent. +- **A mandatory permanent backup for every upgrade** — is not needed for atomicity and cannot promise the same physical representation across JSONL and SQLite. Backends may add recovery copies as a separate product policy without changing migrations. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index ac1088527b..aa25748286 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -14,13 +14,21 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 -**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。 +**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatStep` 组成完整链路,缺失任何一步都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。 + +**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每一步实现 `migrateHeader()` 和惰性的 `migrateEvents()` 转换,只能前进一个版本,也不能改变 Session id 或 cwd。只要发生版本转换,就读取完整事件流,并在所有步骤完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。 + +**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。 + +**Recovery 和写回只消费当前格式数据。**`inspect()` 和 `readFrom()` 只在内存中解码。Cold `prepare()`/`load()` 先解码完整 source,补充当前 recovery closers,再用完整、平衡的当前格式 stream 替换精确的旧 revision。Live HMR adoption 在 seed 校验后使用同一个 replacement primitive,但不会为仍由 live Session 掌握的 turn 合成 closer。替换成功或 revision 冲突后都会丢弃 prepared object,重新打开持久化 source 后再继续。 + +**Replacement 是 backend 内部的 compare-and-swap。**`replaceStored(expectedRevision, meta, events)` 接受流式当前格式日志,并在提交排他区内检查存储身份和 source revision。JSONL 写入并 fsync 同目录临时 artifact,在持有跨进程锁时复核 revision,原子替换路径(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录。SQLite 先暂存 event iterator,再在一个事务中复核并替换 header 与 event rows。提交失败后只会留下完整旧日志或完整新日志;永久保留升级前副本是独立的恢复策略,不属于 format migration API。 **逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 ## 影响 -v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 +Format v0 包含:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 15)和 BFF 线上 schema 接受;以及使用空相邻版本注册表的静态流式 migration decoder。`SESSION_FORMAT_VERSION` 保持 0,直到真实 v0→v1 步骤合入。Decoder 和 backend replacement API 因此可以直接测试,不需要制造一次 format bump。写入侧目前不写 `ignorable`,因为还没有生产者需要它。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话;拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 字段、解码任何 event record 之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 ## 曾考虑的替代方案 @@ -28,3 +36,6 @@ v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径 - **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 - **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。 - **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。 +- **把 migration 物化为 header 和 event 数组**:即使每步转换只依赖单条 record,也会让框架内存占用与完整日志大小成正比。可重复、绑定 revision 的 reader 加 iterator 转换保留重试语义,又不强制这笔分配。 +- **在 `PersistenceCoordinator` 内写版本转换**:会把格式解码和各操作不同的 crash recovery 混在一起,并在 inspect、suffix read、cold continuation 和 live adoption 间复制行为。共享 decoder 只产出当前格式数据,各 consumer 保留自己的 recovery intent。 +- **每次升级都强制永久备份**:原子性不依赖永久副本,而且 JSONL 与 SQLite 无法承诺相同的物理表示。Backend 可以把恢复副本作为独立产品策略加入,不需要修改 migration。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 01ab6a6605..4fb0df3fdc 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 3a471ea06e911d3d29ebef4ce80353b15a21cb43 -config-catalog.zh.md: f1b53d4a2b3c15abb3176c7af5de9c577f74825f +config-catalog.md: 8f3ed736b565b2251bc8418bd940f222f323f0b6 +config-catalog.zh.md: f494277de8f820abe318a260dfbf41dc54e0c947 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3a471ea06e..8f3ed736b5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1590,7 +1590,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1635,7 +1635,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f1b53d4a2b..f494277de8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1592,7 +1592,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -来源:[`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) +来源:[`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1637,7 +1637,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index ef642082ac..4f96170355 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -21,7 +21,7 @@ import { PersistenceCoordinator, SessionPersistenceRevision, type PersistenceBackend, - type StoredPrefix, + type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -271,19 +271,29 @@ describe('cold history recovery view', () => { await ctx.plugin(UserQuestionService) const sessionId = sid('session-interrupted') const meta = header(sessionId, 1000) - const stored: StoredPrefix = { + const revision = SessionPersistenceRevision('history-recovery-test:1') + const stored: StoredSessionSource = { meta, - events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], - revision: SessionPersistenceRevision('history-recovery-test:1'), + revision, + readEvents: ({ fromSeq = 0 } = {}) => ({ + events: (async function* () { + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ] + for (const event of events.slice(fromSeq)) yield structuredClone(event) + })(), + completed: Promise.resolve({}), + }), } const backend: PersistenceBackend = { name: 'history-recovery-test', - loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined), + openStored: id => Promise.resolve(id === sessionId ? stored : undefined), readStoredRevision: id => Promise.resolve( - id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined, + id === sessionId ? revision : undefined, ), appendBatch: () => Promise.resolve(), commitRepair: () => Promise.resolve(), + replaceStored: () => Promise.resolve(), list: () => Promise.resolve([structuredClone(meta)]), } const coordinator = new PersistenceCoordinator(ctx, backend) diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 2923b9e09b..bf6d1fde2d 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -11,7 +11,6 @@ import { join } from 'node:path' import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' -import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence' /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' @@ -224,29 +223,26 @@ export function eventLines(events: readonly SessionEvent[], packChunks: boolean) } interface SessionLogScan { - meta: SessionHeader - events: SessionEvent[] + meta: unknown + events: unknown[] committedBytes: number } -/** Parse one complete header record supplied independently from event rows. */ -/** - * Refuse a header carrying a format version this build does not read BEFORE - * validating the current header shape or decoding any event row: a future - * format need not satisfy today's structural checks at all, and its user must - * see "upgrade the harness", never "corrupt session log". - * @param parsed - the JSON-parsed first line of a session artifact. - */ -function refuseForeignFormatVersion(parsed: unknown): void { - if (typeof parsed !== 'object' || parsed === null) return - const { version, id } = parsed as { version?: unknown; id?: unknown } - if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return - throw new SessionFormatUnsupportedError( - sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version), - ) +/** Parse the version-independent identity fields from one physical header row. */ +function parseStoredHeader(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined + const record = value as Record + if (record['type'] !== 'session' + || !Number.isSafeInteger(record['version'])) return undefined + if (record['version'] === SESSION_FORMAT_VERSION) { + return isHeaderLine(record) ? fromHeaderLine(record) as unknown as Record : undefined + } + const { type: _type, ...meta } = record + return meta } -function parseHeaderRecord(record: Buffer): SessionHeader { +/** Parse one complete header record supplied independently from event rows. */ +function parseHeaderRecord(record: Buffer): unknown { if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { throw new Error('empty or header-less session log') } @@ -256,11 +252,11 @@ function parseHeaderRecord(record: Buffer): SessionHeader { } catch { throw new Error('corrupt session log: header line is not valid JSON') } - refuseForeignFormatVersion(parsed) - if (!isHeaderLine(parsed)) { + const meta = parseStoredHeader(parsed) + if (meta === undefined) { throw new Error('corrupt session log: first line is not a session header') } - return fromHeaderLine(parsed) + return meta } /** @@ -270,8 +266,8 @@ function parseHeaderRecord(record: Buffer): SessionHeader { * copied because a decoder may reuse its output buffer after `write()` returns. */ export class SessionLogScanner { - private readonly meta: SessionHeader - private readonly events: SessionEvent[] = [] + private readonly meta: unknown + private readonly events: unknown[] = [] private fragments: Buffer[] = [] private fragmentBytes = 0 private inputBytes: number @@ -346,7 +342,7 @@ export class SessionLogScanner { /** Decode one complete event row and update the contiguous prefix. */ private consumeEventLine(line: Buffer, endByte: number): void { this.eventLine += 1 - let decoded: SessionEvent[] + let decoded: unknown[] try { decoded = decodeStorageRecord(JSON.parse(line.toString('utf8'))) } catch { @@ -355,20 +351,21 @@ export class SessionLogScanner { } if (this.issue !== undefined) { - if (decoded.some(event => event.type === 'turn/end')) throw this.issue + if (decoded.some(event => (event as { type?: unknown }).type === 'turn/end')) throw this.issue return } const rowStart = this.events.length for (const event of decoded) { - if (event.seq !== this.events.length) { + const seq = (event as { seq?: unknown }).seq + if (seq !== this.events.length) { const expected = this.events.length this.events.length = rowStart this.issue = new Error( `corrupt session log: seq gap in committed region at line ${this.eventLine} ` - + `(expected ${expected}, got ${event.seq})`, + + `(expected ${expected}, got ${String(seq)})`, ) - if (decoded.some(candidate => candidate.type === 'turn/end')) throw this.issue + if (decoded.some(candidate => (candidate as { type?: unknown }).type === 'turn/end')) throw this.issue return } this.events.push(event) @@ -394,20 +391,18 @@ export function scanLog(buffer: Buffer): SessionLogScan { } /** - * Parse just the header line of a log into a {@link SessionHeader}, or - * `undefined` if it is missing/not a header. Used by `list()` to read session - * metadata WITHOUT parsing the whole log: a session picker scales with the - * number of sessions, not the total size of every conversation. - * @param firstLine - the first line of a log file (without its trailing newline). - * @returns the parsed header, or `undefined` when the line is not a well-formed session header. + * Parse only the version-independent identity envelope from one physical + * header line. Format migration and current validation run in the persistence + * decoder. + * @param firstLine - first JSONL record without its newline. + * @returns normalized logical header JSON, or `undefined` for invalid framing. */ -export function parseHeaderMeta(firstLine: string): SessionHeader | undefined { +export function parseStoredHeaderMeta(firstLine: string): unknown { let parsed: unknown try { parsed = JSON.parse(firstLine) } catch { return undefined } - if (!isHeaderLine(parsed)) return undefined - return fromHeaderLine(parsed) + return parseStoredHeader(parsed) } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 5113746fec..e16f343c8f 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -9,28 +9,30 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { readdirSync } from 'node:fs' -import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, realpath, link, rename, rm, stat, truncate, writeFile } from 'node:fs/promises' 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 { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, + decodeStoredSessionHeader, SessionPersistence, SessionPersistenceRevision, + SessionPersistenceRevisionConflictError, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, - type StoredPrefix, + type StoredEventRead, type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, + encodeSegment, eventLines, logPath, logSuffix, parseStoredHeaderMeta, projectDir, scanLog, sessionDir, SessionLogScanner, toHeaderLine, type JsonlCompression, } from './format.ts' import { compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, } from './zstd.ts' -import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts' +import { ensureDurableDirectoryWin32, publishNewFileWin32, replaceFileWin32 } from './win32.ts' export type { JsonlCompression } from './format.ts' @@ -42,6 +44,10 @@ const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' * remains an indivisible synchronous decode. */ const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 +const LOG_LOCK_RETRY_INITIAL_MS = 20 +const LOG_LOCK_RETRY_MAX_MS = 200 +const LOG_LOCK_TIMEOUT_MS = 2_000 +const REPLACEMENT_BATCH_SIZE = 128 /** Assert that the independently decodable first frame contains only the header record. */ function assertZstdHeaderFrame(plaintext: Buffer): void { @@ -88,6 +94,32 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } +interface JsonlStoredPrefix { + readonly meta: unknown + readonly events: unknown[] + readonly revision: PersistenceRevision + readonly tornMarker?: JsonlTornMarker +} + +interface JsonlStoredHeader { + readonly meta: unknown + readonly revision: PersistenceRevision +} + +function deferred(): { + readonly promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + interface FileRevisionIdentity { readonly dev: bigint readonly ino: bigint @@ -112,6 +144,10 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + /** * The JSONL persistence backend. Load as a plugin; it registers as * `ctx.sessionPersistence` and (via the coordinator) installs the write-path @@ -193,8 +229,8 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi return this.coordinator.inspect(id, signal) } - // JSONL is sequential media: no loadStoredFrom hook, so the coordinator - // parses the stored prefix (both encodings) and skips forward to fromSeq. + // JSONL is sequential media: its source reader parses the stored prefix and + // filters only after physical framing and sequence checks. readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.coordinator.readFrom(id, fromSeq, signal) } @@ -205,14 +241,43 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi /* jscpd:ignore-end */ // --- PersistenceBackend hooks (the file-bytes storage primitives) --- - /** Read a stored prefix by id across all project directories when cwd is unknown. */ - async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + /** Open repeatable reads over one revision resolved across project directories. */ + async openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { signal?.throwIfAborted() await this.ensureRootEncoding() signal?.throwIfAborted() const path = await this.findLog(id, signal) if (path === undefined) return undefined - return this.readPrefix(path, id, signal) + const { meta, revision } = await this.readStoredHeader(path, id, signal) + return { + meta, + revision, + location: { kind: 'jsonl', path }, + readEvents: (options = {}): StoredEventRead => { + const completed = deferred<{ tornMarker?: JsonlTornMarker }>() + const events = (async function* (backend: JsonlSessionPersistence): AsyncIterable { + try { + const prefix = await backend.readPrefix(path, id, signal) + if (prefix.revision !== revision) { + throw new SessionPersistenceRevisionConflictError( + `session "${id}" changed while reading revision ${revision}`, + ) + } + const fromSeq = options.fromSeq ?? 0 + for (const event of prefix.events) { + signal?.throwIfAborted() + const seq = (event as { seq?: unknown }).seq + if (typeof seq !== 'number' || seq >= fromSeq) yield event + } + completed.resolve(prefix.tornMarker === undefined ? {} : { tornMarker: prefix.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })(this) + return { events, completed: completed.promise } + }, + } } /** @@ -272,10 +337,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } else { content = buffer.toString('utf8') } - const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) - if (meta === undefined || meta.id !== id) { + const rawMeta = parseStoredHeaderMeta(content.split('\n', 1)[0] as string) + if (rawMeta === undefined) { throw new Error(`corrupt session log: invalid header line in "${path}"`) } + const meta = decodeStoredSessionHeader(rawMeta, id, { kind: 'jsonl', path }) // The logical artifact name is `session.jsonl` regardless of the physical // encoding suffix (`.jsonl.zstd` marks compression only). return { meta, filename: 'session.jsonl', content } @@ -303,6 +369,31 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } } + /** Read one version-independent header at a stable file revision. */ + private async readStoredHeader( + path: string, + _expectedId?: SessionId, + signal?: AbortSignal, + ): Promise { + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + const firstLine = this.compression === 'zstd' + ? await this.readFirstZstdLine(path, signal) + : await this.readFirstLine(path, signal) + const after = fileRevision(await stat(path, { bigint: true })) + if (before !== after) continue + if (firstLine === undefined) { + throw new Error(this.compression === 'zstd' + ? `empty or header-less Zstandard session log at "${path}"` + : `empty or header-less session log at "${path}"`) + } + const meta = parseStoredHeaderMeta(firstLine) + if (meta === undefined) throw new Error(`corrupt session log: first line is not a session header in "${path}"`) + return { meta, revision: after } + } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. @@ -311,32 +402,22 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi path: string, expectedId?: SessionId, signal?: AbortSignal, - ): Promise> { + ): Promise { const { buffer, revision } = await this.readStableFile(path, signal) - let prefix: Omit, 'revision'> - try { - if (this.compression === 'zstd') { - prefix = await this.readZstdPrefix(buffer, signal) - } else { - signal?.throwIfAborted() - const { meta, events, committedBytes } = scanLog(buffer) - signal?.throwIfAborted() - prefix = { - meta, - events, - ...committedBytes < buffer.byteLength - ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } - : {}, - } + let prefix: Omit + if (this.compression === 'zstd') { + prefix = await this.readZstdPrefix(buffer, signal) + } else { + signal?.throwIfAborted() + const { meta, events, committedBytes } = scanLog(buffer) + signal?.throwIfAborted() + prefix = { + meta, + events, + ...committedBytes < buffer.byteLength + ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } + : {}, } - } catch (error: unknown) { - // A parse-time format refusal predates any SessionHeader, so the - // coordinator's locate-based enrichment cannot run; attach the artifact - // this read actually refused. - if (error instanceof SessionFormatUnsupportedError && error.location === undefined) { - throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) - } - throw error } signal?.throwIfAborted() await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) @@ -348,7 +429,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi private async readZstdPrefix( buffer: Buffer, signal?: AbortSignal, - ): Promise, 'revision'>> { + ): Promise> { signal?.throwIfAborted() const { frames, tornStart } = scanZstdFrames(buffer) signal?.throwIfAborted() @@ -406,7 +487,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi events: recoveredPrefix.events, tornMarker: { truncateTo: tornStart, - recoveredEvents: recoveredPrefix.events.slice(complete.eventCount), + recoveredEvents: recoveredPrefix.events.slice(complete.eventCount) as SessionEvent[], }, } } catch (error) { @@ -422,7 +503,8 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { await this.ensureRootEncoding() if (isMaterialized) { - await this.appendLines(meta, events) + const path = logPath(this.root, meta.cwd, meta.id, this.compression) + await this.withLogLock(path, () => this.appendLines(meta, events)) } else { await this.materialize(meta, events) } @@ -438,9 +520,69 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi tornMarker: JsonlTornMarker | undefined, closers: readonly SessionEvent[], ): Promise { - if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) - const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] - if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) + const path = logPath(this.root, meta.cwd, meta.id, this.compression) + await this.withLogLock(path, async () => { + if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) + const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] + if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) + }) + } + + /** Replace one exact source revision through a synced sibling and atomic namespace update. */ + async replaceStored( + expectedRevision: PersistenceRevision, + meta: SessionHeader, + events: AsyncIterable, + ): Promise { + await this.ensureRootEncoding() + const path = await this.findLog(meta.id) + if (path === undefined) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" no longer has revision ${expectedRevision}`, + ) + } + await this.withLogLock(path, async () => { + let current: JsonlStoredHeader + try { + current = await this.readStoredHeader(path, meta.id) + } catch (error: unknown) { + if (isENOENT(error)) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" no longer has revision ${expectedRevision}`, + ) + } + throw error + } + if (current.revision !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + const currentIdentity = this.storedIdentity(current.meta, path) + if (meta.cwd !== currentIdentity.cwd) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + + const tmp = `${path}.${randomBytes(6).toString('hex')}.upgrade.tmp` + try { + await this.writeReplacement(tmp, meta, events) + const beforeCommit = fileRevision(await stat(path, { bigint: true })) + if (beforeCommit !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + /* v8 ignore next -- Windows uses its write-through replacement primitive. */ + if (process.platform === 'win32') { + await replaceFileWin32(tmp, path) + } else { + await rename(tmp, path) + await this.syncDirPosix(dirname(path)) + } + } finally { + await rm(tmp, { force: true }) + } + }) } /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ @@ -493,9 +635,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi : await this.readFirstLine(path, signal) signal?.throwIfAborted() if (first === undefined) continue // empty/half-written file - const meta = parseHeaderMeta(first) - if (meta === undefined) continue // not a session header - await this.assertStoredIdentity(path, meta, undefined, signal) + const rawMeta = parseStoredHeaderMeta(first) + if (rawMeta === undefined) continue // not a session header + const identity = this.storedIdentity(rawMeta, path) + const meta = decodeStoredSessionHeader(rawMeta, identity.id, { kind: 'jsonl', path }) + await this.assertStoredIdentity(path, rawMeta, undefined, signal) signal?.throwIfAborted() if (ids.has(meta.id)) { throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`) @@ -615,6 +759,59 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi return tmp } + /** Stream one complete current-format replacement into a synced temp file. */ + private async writeReplacement( + path: string, + meta: SessionHeader, + events: AsyncIterable, + ): Promise { + const handle = await open(path, 'wx', 0o600) + try { + const header = JSON.stringify(toHeaderLine(meta)) + '\n' + await handle.writeFile(this.compression === 'zstd' ? await compressZstdFrame(header) : header) + let batch: SessionEvent[] = [] + const writeBatch = async (): Promise => { + if (batch.length === 0) return + const body = eventLines(batch, this.packChunks) + '\n' + await handle.writeFile(this.compression === 'zstd' ? await compressZstdFrame(body) : body) + batch = [] + } + for await (const event of events) { + batch.push(event) + if (batch.length === REPLACEMENT_BATCH_SIZE) await writeBatch() + } + await writeBatch() + await handle.sync() + } finally { + await handle.close() + } + } + + /** Serialize cooperating cross-process mutations of one materialized log. */ + private async withLogLock(path: string, operation: () => Promise): Promise { + const lockPath = `${path}.lock` + const deadline = Date.now() + LOG_LOCK_TIMEOUT_MS + let delay = LOG_LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error: unknown) { + if (!isEEXIST(error)) throw error + } + if (Date.now() >= deadline) { + throw new Error(`session log writer lock timed out at "${lockPath}"`) + } + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, LOG_LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } + } + /** Encode the header and first batch without combining their frame boundaries. */ private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const header = JSON.stringify(toHeaderLine(meta)) + '\n' @@ -807,26 +1004,45 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi /** Reject metadata that does not identify the selected physical log. */ private async assertStoredIdentity( path: string, - meta: SessionHeader, + meta: unknown, expectedId?: SessionId, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() - if (expectedId !== undefined && meta.id !== expectedId) { - throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`) + const identity = this.storedIdentity(meta, path) + if (expectedId !== undefined && identity.id !== expectedId) { + throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${identity.id}"`) } let expectedPath: string try { - expectedPath = logPath(this.root, meta.cwd, meta.id, this.compression) + expectedPath = logPath(this.root, identity.cwd, identity.id, this.compression) } catch (error) { throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error }) } if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) { - throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`) + throw new Error(`corrupt session log "${path}": header id "${identity.id}" and cwd identify "${expectedPath}"`) } signal?.throwIfAborted() } + /** Read storage identity fields shared by every Session format version. */ + private storedIdentity(meta: unknown, path: string): { id: SessionId; cwd?: string } { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + throw new Error(`corrupt session log "${path}": header is not a record`) + } + const record = meta as Record + if (typeof record['id'] !== 'string') { + throw new Error(`corrupt session log "${path}": header id is not a string`) + } + if (record['cwd'] !== undefined && typeof record['cwd'] !== 'string') { + throw new Error(`corrupt session log "${path}": header cwd is not a string`) + } + return { + id: SessionId(record['id']), + ...typeof record['cwd'] === 'string' ? { cwd: record['cwd'] } : {}, + } + } + /** * Whether two path spellings resolve to the same physical file. This admits * case aliases on case-insensitive filesystems without weakening identity diff --git a/packages/session/session-persistence-jsonl/src/win32.ts b/packages/session/session-persistence-jsonl/src/win32.ts index c3fa852b08..51456bd740 100644 --- a/packages/session/session-persistence-jsonl/src/win32.ts +++ b/packages/session/session-persistence-jsonl/src/win32.ts @@ -28,6 +28,7 @@ interface Win32ErrnoException extends NodeJS.ErrnoException { } const MOVEFILE_WRITE_THROUGH = 0x00000008 +const MOVEFILE_REPLACE_EXISTING = 0x00000001 const ERROR_FILE_NOT_FOUND = 2 const ERROR_PATH_NOT_FOUND = 3 const ERROR_ACCESS_DENIED = 5 @@ -119,6 +120,19 @@ export async function publishNewFileWin32(existing: string, replacement: string) if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) } +/** + * Atomically replace an existing file with a synced staging file and request + * write-through namespace durability. The move stays within one volume. + * @param existing - synced staging path to move. + * @param replacement - existing final path to replace. + */ +export async function replaceFileWin32(existing: string, replacement: string): Promise { + const api = await win32() + const flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH + const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), flags) + if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) +} + /** * Create `target` and its missing ancestors with durable Windows namespace * publication. Each missing directory is first created as a random staging diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 70ae6d1ac5..ce6e99f30c 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -8,7 +8,12 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { - encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine, + SessionPersistenceRevisionConflictError, + type StoredEventRead, +} from '@deepseek-ai/dsh-session-persistence' +import { + encodeSegment, eventLines, fromHeaderLine, logPath, parseStoredHeaderMeta, projectDir, projectKey, scanLog, sessionDir, + SessionLogScanner, toHeaderLine, } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -16,6 +21,8 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p const statRace = vi.hoisted(() => ({ path: undefined as string | undefined, reads: 0, + renamePath: undefined as string | undefined, + renameError: undefined as Error | undefined, })) vi.mock('node:fs/promises', async (importOriginal) => { @@ -29,6 +36,25 @@ vi.mock('node:fs/promises', async (importOriginal) => { if (statRace.reads !== 2) return identity return { ...identity, mtimeNs: identity.mtimeNs + 1n } }) as typeof actual.stat, + rename: async (...args: Parameters) => { + if (String(args[1]) === statRace.renamePath && statRace.renameError !== undefined) { + throw statRace.renameError + } + return actual.rename(...args) + }, + } +}) + +vi.mock('../src/win32.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + replaceFileWin32: async (existing: string, replacement: string) => { + if (replacement === statRace.renamePath && statRace.renameError !== undefined) { + throw statRace.renameError + } + return actual.replaceFileWin32(existing, replacement) + }, } }) @@ -42,6 +68,17 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader { return header } +async function collectStoredRead(read: StoredEventRead): Promise { + const events: unknown[] = [] + for await (const event of read.events) events.push(event) + await read.completed + return events +} + +async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable { + for (const event of events) yield structuredClone(event) +} + /** Rewrite only a stored header while preserving every event byte below it. */ async function rewriteHeader(path: string, update: (header: Record) => void): Promise { const lines = (await readFile(path, 'utf8')).split('\n') @@ -86,6 +123,8 @@ function rawLogPath(root: string, cwd: string | undefined, id: SessionId): strin afterEach(async () => { statRace.path = undefined statRace.reads = 0 + statRace.renamePath = undefined + statRace.renameError = undefined vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) @@ -134,6 +173,18 @@ runCoordinatorContract('jsonl-none', async (): Promise => { }) describe('JsonlSessionPersistence: format helpers', () => { + it('parses only the version-independent stored header envelope', () => { + expect(parseStoredHeaderMeta('{')).toBeUndefined() + expect(parseStoredHeaderMeta('42')).toBeUndefined() + expect(parseStoredHeaderMeta(JSON.stringify({ type: 'event', version: 9, id: 'wrong-type' }))) + .toBeUndefined() + expect(parseStoredHeaderMeta(JSON.stringify({ type: 'session', version: 9, id: 'future', futureOnly: true }))) + .toEqual({ version: 9, id: 'future', futureOnly: true }) + expect(parseStoredHeaderMeta(JSON.stringify({ + type: 'session', version: 0, id: 'current', createdAt: 1, delegationDepth: 0, + }))).toEqual({ version: 0, id: 'current', createdAt: 1, delegationDepth: 0 }) + }) + it('encodeSegment neutralizes traversal, separators, and absolute paths', () => { expect(encodeSegment('..')).toBe('~002E~002E') expect(encodeSegment('.')).toBe('~002E') @@ -299,7 +350,8 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) const scanned = scanLog(Buffer.from(raw!.content)) - expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + expect(scanned.events.map(event => (event as SessionEvent).type)) + .toEqual(oneTurnLog().map(event => event.type)) }) it('readRaw is undefined for an absent session', async () => { @@ -397,28 +449,287 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { await otherCtx.fiber.dispose() }) - it('binds a full stored prefix to the same revision as a lightweight read', async () => { + it('binds a stored source to the same revision as a lightweight read', async () => { const m = meta('stored-prefix-revision') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const persistence = ctx.sessionPersistence as JsonlSessionPersistence - const stored = await persistence.loadStored(m.id) + const stored = await persistence.openStored(m.id) expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() }) - it('retries a full-prefix read when the file revision changes during the read', async () => { + it('retries a revision-bound source read when the file changes during the read', async () => { const m = meta('stored-prefix-revision-race') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const stored = await persistence.openStored(m.id) + if (stored === undefined) throw new Error('test session must be materialized') statRace.path = rawLogPath(root, m.cwd, m.id) - await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() }) + await expect(collectStoredRead(stored.readEvents())).resolves.toEqual(oneTurnLog()) expect(statRace.reads).toBe(4) }) + it('rejects a revision-bound source after a complete append changes its revision', async () => { + const m = meta('stored-source-stale') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const stored = await persistence.openStored(m.id) + if (stored === undefined) throw new Error('test session must be materialized') + + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } }, + ]) + + const read = stored.readEvents() + const completion = read.completed.catch((error: unknown) => error) + await expect(collectStoredRead(read)).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await expect(completion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError) + }) + + it('retries a header read whose revision changes around the first-line read', async () => { + const m = meta('stored-header-revision-race') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const path = rawLogPath(root, m.cwd, m.id) + const internals = persistence as unknown as { + findLog(id: SessionId): Promise + } + vi.spyOn(internals, 'findLog').mockResolvedValue(path) + statRace.path = path + + await expect(persistence.openStored(m.id)).resolves.toMatchObject({ meta: { id: m.id } }) + expect(statRace.reads).toBe(4) + }) + + it('reports a present empty plaintext artifact as header-less', async () => { + const m = meta('empty-plaintext-log') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeFile(rawLogPath(root, m.cwd, m.id), '') + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + + await expect(persistence.openStored(m.id)) + .rejects.toThrow('empty or header-less session log') + }) + + it('forwards prepare through the concrete backend API', async () => { + const m = meta('jsonl-prepare-forward') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + + const preparation = await persistence.prepare(m.id) + expect(preparation.session.id).toBe(m.id) + preparation[Symbol.dispose]() + }) + + it('atomically replaces one exact revision and rejects a stale replacement', async () => { + const m = meta('format-replace', '/work') + const original = [ + ...oneTurnLog(), + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, original) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog())) + const replaced = await persistence.openStored(m.id) + if (replaced === undefined) throw new Error('replacement must preserve the session') + expect(replaced.revision).not.toBe(source.revision) + expect(await collectStoredRead(replaced.readEvents())).toEqual(oneTurnLog()) + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents(original)), + ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + const afterConflict = await persistence.openStored(m.id) + if (afterConflict === undefined) throw new Error('conflict must preserve the session') + expect(await collectStoredRead(afterConflict.readEvents())).toEqual(oneTurnLog()) + }) + + it('preserves the old complete log when atomic replacement rename fails', async () => { + const m = meta('format-replace-rename-failure', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const path = rawLogPath(root, m.cwd, m.id) + const failure = new Error('simulated format replacement rename failure') + statRace.renamePath = path + statRace.renameError = failure + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents([])), + ).rejects.toBe(failure) + + statRace.renameError = undefined + const preserved = await persistence.openStored(m.id) + if (preserved === undefined) throw new Error('failed replacement must preserve the session') + expect(await collectStoredRead(preserved.readEvents())).toEqual(oneTurnLog()) + expect((await readdir(dirname(path))).some(name => name.endsWith('.upgrade.tmp'))).toBe(false) + }) + + it('rejects replacement when the artifact disappears before or after discovery', async () => { + const m = meta('format-replace-disappeared', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const path = rawLogPath(root, m.cwd, m.id) + + await rm(path) + await expect(persistence.replaceStored(source.revision, m, replacementEvents([]))) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + + const internals = persistence as unknown as { + findLog(id: SessionId): Promise + } + vi.spyOn(internals, 'findLog').mockResolvedValue(path) + await expect(persistence.replaceStored(source.revision, m, replacementEvents([]))) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + }) + + it('propagates a non-absence error while rechecking a replacement source', async () => { + const m = meta('format-replace-header-error', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const failure = new Error('simulated header read failure') + const internals = persistence as unknown as { + readStoredHeader(path: string, id: SessionId): Promise + } + vi.spyOn(internals, 'readStoredHeader').mockRejectedValue(failure) + + await expect(persistence.replaceStored(source.revision, m, replacementEvents([]))) + .rejects.toBe(failure) + }) + + it('rejects a replacement that changes cwd storage identity', async () => { + const m = meta('format-replace-identity', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await expect(persistence.replaceStored( + source.revision, + { ...m, cwd: '/other' }, + replacementEvents(oneTurnLog()), + )).rejects.toThrow(/changes its stored identity/) + }) + + it('rejects a replacement when the source changes after the temp file is synced', async () => { + const m = meta('format-replace-final-cas', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const path = rawLogPath(root, m.cwd, m.id) + const internals = persistence as unknown as { + writeReplacement(path: string, meta: SessionHeader, events: AsyncIterable): Promise + } + const writeReplacement = internals.writeReplacement.bind(internals) + vi.spyOn(internals, 'writeReplacement').mockImplementation(async (...args) => { + await writeReplacement(...args) + await appendFile(path, '\n') + }) + + await expect(persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog()))) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + expect((await readdir(dirname(path))).some(name => name.endsWith('.upgrade.tmp'))).toBe(false) + }) + + it('streams replacement events in bounded batches', async () => { + const m = meta('format-replace-batches', '/work') + const events = Array.from({ length: 128 }, (_, seq): SessionEvent => ({ + type: 'turn/start', seq, time: seq + 1, data: { turn: seq + 1 }, + })) + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await persistence.replaceStored(source.revision, m, replacementEvents(events)) + + const replaced = await persistence.openStored(m.id) + if (replaced === undefined) throw new Error('replacement must preserve the session') + expect(await collectStoredRead(replaced.readEvents())).toHaveLength(128) + }) + + it('retries a held writer lock and rejects lock acquisition failures and timeout', async () => { + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const internals = persistence as unknown as { + withLogLock(path: string, operation: () => Promise): Promise + } + const path = join(root, 'lock-target') + const lockPath = `${path}.lock` + await writeFile(lockPath, 'held\n') + const released = new Promise((resolveRelease) => { + setTimeout(() => { void rm(lockPath).then(() => { resolveRelease() }) }, 5) + }) + await expect(internals.withLogLock(path, async () => 'committed')).resolves.toBe('committed') + await released + + await expect(internals.withLogLock(`${root}\0invalid`, async () => undefined)) + .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) + + await writeFile(lockPath, 'held\n') + const now = vi.spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(3_001) + await expect(internals.withLogLock(path, async () => undefined)) + .rejects.toThrow(/writer lock timed out/) + now.mockRestore() + }) + + it('rejects malformed version-independent storage identity fields', async () => { + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const path = rawLogPath(root, '/work', SessionId('identity-fields')) + const internals = persistence as unknown as { + storedIdentity(meta: unknown, path: string): { id: SessionId; cwd?: string } + } + + expect(() => internals.storedIdentity(null, path)).toThrow(/header is not a record/) + expect(() => internals.storedIdentity({ id: 1 }, path)).toThrow(/header id is not a string/) + expect(() => internals.storedIdentity({ id: 'identity-fields', cwd: 1 }, path)) + .toThrow(/header cwd is not a string/) + }) + + it('rejects a physical log whose requested id differs from its header id', async () => { + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const requested = SessionId('requested-identity') + const path = rawLogPath(root, '/work', requested) + const internals = persistence as unknown as { + assertStoredIdentity( + path: string, + meta: unknown, + expectedId?: SessionId, + ): Promise + } + + await expect(internals.assertStoredIdentity( + path, + { id: 'different-identity', cwd: '/work' }, + requested, + )).rejects.toThrow(/requested id .* does not match header id/) + }) + it('handles revision-stat races and errors after log discovery', async () => { const m = meta('stored-revision-race') await ctx.sessionPersistence.create(m) @@ -748,7 +1059,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { const beforeB = await readFile(bPath) await expect(ctx.sessionPersistence.load(a.id)) - .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/) + .rejects.toThrow(/identity mismatch: requested "identity-a", header contains "identity-b"/) expect(await readFile(aPath)).toEqual(beforeA) expect(await readFile(bPath)).toEqual(beforeB) }) @@ -939,7 +1250,20 @@ describe('JsonlSessionPersistence: scanLog unit', () => { // The preset decides the resumed session's tools and prompt; dropping it // on disk would restore a composition the logged history contradicts. - expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal') + expect((scanLog(Buffer.from(log)).meta as SessionHeader).agentPreset).toBe('minimal') + }) + + it('round-trips and validates a subagent origin', () => { + const header: SessionHeader = { + ...meta('subagent-origin'), + delegationDepth: 1, + origin: 'subagent', + } + const line = toHeaderLine(header) + + expect(fromHeaderLine(line)).toEqual(header) + expect(() => scanLog(Buffer.from(`${JSON.stringify({ ...line, origin: 'parent' })}\n`))) + .toThrow(/session header/) }) it('rejects a session header whose agentPreset is not a string', () => { @@ -957,7 +1281,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn. - expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) + expect(scanLog(Buffer.from(log)).events.map(e => (e as SessionEvent).seq)).toEqual([0]) }) it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { @@ -997,7 +1321,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { ].join('\n') + '\n' // The contiguous prefix (turn/start seq 0) is preserved; the corrupt // fragment after it is the tolerated crash boundary. - expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) + expect(scanLog(Buffer.from(log)).events.map(e => (e as SessionEvent).seq)).toEqual([0]) }) it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { @@ -1008,7 +1332,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail ].join('\n') + '\n' const { events } = scanLog(Buffer.from(log)) - expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped + expect(events.map(e => (e as SessionEvent).seq)).toEqual([0, 1]) // tail dropped }) }) @@ -1129,7 +1453,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' const { events } = scanLog(Buffer.from(logText)) - expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4]) + expect(events.map(e => (e as SessionEvent).seq)).toEqual([0, 1, 2, 3, 4]) expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } }) }) @@ -1151,7 +1475,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), ].join('\n') + '\n' const scanned = scanLog(Buffer.from(logText)) - expect(scanned.events.map(e => e.seq)).toEqual([0]) + expect(scanned.events.map(e => (e as SessionEvent).seq)).toEqual([0]) // committedBytes stays on the line boundary BEFORE the dropped row. const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n' expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8')) @@ -1393,7 +1717,7 @@ describe('JsonlSessionPersistence: edge cases', () => { // The "/w" log is untouched — no no-cwd events were grafted onto it, and no // `_no-cwd` log for "x" was created. const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x')))) - expect(inW.meta.cwd).toBe('/w') + expect((inW.meta as SessionHeader).cwd).toBe('/w') expect(inW.events).toHaveLength(6) await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow() await ctx2.fiber.dispose() diff --git a/packages/session/session-persistence-jsonl/tests/win32.spec.ts b/packages/session/session-persistence-jsonl/tests/win32.spec.ts index 3b6cfc4f78..33b8d2328f 100644 --- a/packages/session/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/win32.spec.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' const MOVEFILE_WRITE_THROUGH = 0x00000008 +const MOVEFILE_REPLACE_EXISTING = 0x00000001 const ERROR_FILE_NOT_FOUND = 2 const ERROR_PATH_NOT_FOUND = 3 const ERROR_ACCESS_DENIED = 5 @@ -90,6 +91,17 @@ async function importWithFilesystemMove(): Promise { + return importWithMove((existing, replacement, flags, setLastError) => { + expect(flags).toBe(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) + const from = stripNamespace(existing) + const to = stripNamespace(replacement) + if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 } + renameSync(from, to) + return 1 + }) +} + afterEach(async () => { vi.doUnmock('koffi') vi.doUnmock('node:fs/promises') @@ -141,6 +153,30 @@ describe('Windows durable namespace helpers', () => { expect(readFileSync(final, 'utf8')).toBe('content') }) + it('replaces an existing file with write-through MoveFileExW semantics', async () => { + const { replaceFileWin32 } = await importWithFilesystemReplace() + const root = await tempRoot() + const tmp = join(root, 'log.tmp') + const final = join(root, 'log.jsonl') + await writeFile(tmp, 'replacement') + await writeFile(final, 'original') + + await replaceFileWin32(tmp, final) + expect(existsSync(tmp)).toBe(false) + expect(readFileSync(final, 'utf8')).toBe('replacement') + }) + + it('maps a Win32 replacement failure to a Node-style error', async () => { + const { replaceFileWin32 } = await importWithError(ERROR_ACCESS_DENIED) + + await expect(replaceFileWin32('from', 'to')).rejects.toMatchObject({ + code: 'EACCES', + win32Code: ERROR_ACCESS_DENIED, + path: 'from', + dest: 'to', + }) + }) + it('maps Win32 publish failures to Node-style errno codes', async () => { const cases = [ [ERROR_FILE_NOT_FOUND, 'ENOENT'], diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b9cced0087..f77b26427e 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -332,6 +332,27 @@ describe('Zstandard frame structure', () => { }) describe('JsonlSessionPersistence: default Zstandard encoding', () => { + it('atomically replaces a stored revision with compressed header and event frames', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('replace-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(header.id) + if (source === undefined) throw new Error('test session must be materialized') + const replacement = oneTurnLog().slice(0, 2) + + await persistence.replaceStored(source.revision, header, (async function* () { + yield* replacement + })()) + + const buffer = await readFile(logPath(root, header.cwd, header.id, 'zstd')) + expect(scanZstdFrames(buffer).frames).toHaveLength(2) + const plaintext = (await decodeCompleteFrames(buffer)).toString() + expect(scanLog(Buffer.from(plaintext)).events).toEqual(replacement) + }) + it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => { const root = await freshRoot() const ctx = await mount(root) @@ -374,7 +395,8 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { '', ].join('\n')) const scanned = scanLog(Buffer.from(raw!.content)) - expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + expect(scanned.events.map(event => (event as SessionEvent).type)) + .toEqual(oneTurnLog().map(event => event.type)) }) it('readRaw rejects a present zstd artifact that carries no frame', async () => { @@ -387,6 +409,32 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) await expect(ctx.sessionPersistence.readRaw(header.id)) .rejects.toThrow('empty or header-less Zstandard session log') + await expect(ctx.sessionPersistence.load(header.id)) + .rejects.toThrow('empty or header-less Zstandard session log') + }) + + it('rejects a zero-frame artifact through an already-open stored reader', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('stored-zero-frame', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(header.id) + if (source === undefined) throw new Error('test session must be materialized') + await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) + + const read = source.readEvents() + const completion = read.completed.catch((error: unknown) => error) + const consumption = (async (): Promise => { + for await (const _event of read.events) { + // A zero-frame artifact cannot yield a logical event. + } + })().catch((error: unknown) => error) + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + + expect(streamFailure).toBe(completionFailure) + expect(streamFailure).toMatchObject({ message: 'empty or header-less Zstandard session log' }) }) it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { @@ -727,7 +775,7 @@ describe('JsonlSessionPersistence: encoding selection', () => { '', ].join('\n')) await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/) - await expect((ctx.sessionPersistence as JsonlSessionPersistence).loadStored(loadHeader.id)) + await expect((ctx.sessionPersistence as JsonlSessionPersistence).openStored(loadHeader.id)) .rejects.toThrow(/uses \.jsonl/) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/) }) diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 969f1f5fac..68becaec86 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -15,14 +15,16 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + decodeStoredSessionHeader, SessionPersistence, SessionPersistenceRevision, + SessionPersistenceRevisionConflictError, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, - type StoredPrefix, type StoredSuffix, + type StoredEventRead, type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEventType, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { - type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, + type JournalMode, openDatabase, rowToStoredMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' export { SCHEMA_VERSION } from './schema.ts' @@ -50,6 +52,27 @@ function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevi ) } +interface SqliteStoredPrefix { + readonly meta: unknown + readonly events: unknown[] + readonly revision: PersistenceRevision + readonly tornMarker?: number +} + +function deferred(): { + readonly promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. @@ -203,9 +226,43 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers // --- PersistenceBackend hooks (the SQLite storage primitives) --- - /** Read a stored prefix by id (ids are globally unique — no scope to scan). */ - loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { - return this.readPrefix(id, signal) + /** Open repeatable reads over one SQLite row revision. */ + async openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + signal?.throwIfAborted() + await this.ready + signal?.throwIfAborted() + const row = this.rowFor(id) + if (row === undefined) return undefined + const revision = sqliteRevision(this.storeIdentity, row) + return { + meta: rowToStoredMeta(row), + revision, + readEvents: (options = {}): StoredEventRead => { + const completed = deferred<{ tornMarker?: number }>() + const events = (async function* (backend: SqliteSessionPersistence): AsyncIterable { + try { + const fromSeq = options.fromSeq ?? 0 + const stored = fromSeq === 0 + ? await backend.readPrefix(id, signal) + : await backend.readSuffix(id, fromSeq, signal) + if (stored === undefined || stored.revision !== revision) { + throw new SessionPersistenceRevisionConflictError( + `session "${id}" changed while reading revision ${revision}`, + ) + } + for (const event of stored.events) { + signal?.throwIfAborted() + yield event + } + completed.resolve(stored.tornMarker === undefined ? {} : { tornMarker: stored.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })(this) + return { events, completed: completed.promise } + }, + } } /** Read one row's revision without loading its events. */ @@ -222,27 +279,42 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers * read scales with the suffix, not the log. Torn rows past the preserved * region are dropped, never repaired (non-mutating read). */ - async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { + private async readSuffix(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { signal?.throwIfAborted() await this.ready signal?.throwIfAborted() - const row = this.rowFor(id) - if (row === undefined) return undefined - const meta = rowToMeta(row) - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') - .all(id, fromSeq) as unknown as EventRow[] + this.db.exec('BEGIN') + let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined + try { + const row = this.rowFor(id) + if (row !== undefined) { + const eventRows = this.db + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + .all(id, fromSeq) as unknown as EventRow[] + snapshot = { row, eventRows } + } + this.db.exec('COMMIT') + } catch (error: unknown) { + this.db.exec('ROLLBACK') + throw error + } signal?.throwIfAborted() + if (snapshot === undefined) return undefined + const { row, eventRows } = snapshot const { preserved } = scanRows(eventRows, fromSeq) - return { meta, events: preserved } + return { + meta: rowToStoredMeta(row), + events: preserved, + revision: sqliteRevision(this.storeIdentity, row), + } } /** - * Read a session's row + ordered events into a {@link StoredPrefix}. The + * Read a session's row and ordered events at one SQLite snapshot. The * torn-tail marker is the seq from which a never-committed tail must be deleted * (`scanRows` already returns it as `number | undefined`). */ - private async readPrefix(id: SessionId, signal?: AbortSignal): Promise | undefined> { + private async readPrefix(id: SessionId, signal?: AbortSignal): Promise { signal?.throwIfAborted() await this.ready signal?.throwIfAborted() @@ -268,7 +340,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers const { row, eventRows } = snapshot const { preserved, tornFrom } = scanRows(eventRows) return { - meta: rowToMeta(row), + meta: rowToStoredMeta(row), events: preserved, revision: sqliteRevision(this.storeIdentity, row), ...tornFrom !== undefined ? { tornMarker: tornFrom } : {}, @@ -337,6 +409,102 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers } } + /** Stage a streamed replacement, then compare and swap it in one transaction. */ + async replaceStored( + expectedRevision: PersistenceRevision, + meta: SessionHeader, + events: AsyncIterable, + ): Promise { + await this.ready + const observed = this.rowFor(meta.id) + if (observed === undefined + || sqliteRevision(this.storeIdentity, observed) !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + if (meta.cwd !== (observed.cwd ?? undefined)) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + const staging = `format_upgrade_${randomUUID().replaceAll('-', '')}` + this.db.exec(` + CREATE TEMP TABLE ${staging} ( + seq INTEGER PRIMARY KEY, + type TEXT NOT NULL, + time INTEGER NOT NULL, + data TEXT NOT NULL, + source_event_seqs TEXT, + surface_op TEXT, + ignorable INTEGER + ) STRICT + `) + try { + const stageEvent = this.db.prepare( + `INSERT INTO ${staging} (seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + for await (const event of events) { + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + stageEvent.run(event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) + } + + this.db.exec('BEGIN IMMEDIATE') + let began = true + try { + const row = this.rowFor(meta.id) + if (row === undefined + || sqliteRevision(this.storeIdentity, row) !== expectedRevision) { + this.db.exec('ROLLBACK') + began = false + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + if (meta.cwd !== (row.cwd ?? undefined)) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + + this.db.prepare('DELETE FROM events WHERE session_id = ?').run(meta.id) + this.db.prepare(` + INSERT INTO events + (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) + SELECT ?, seq, type, time, data, source_event_seqs, surface_op, ignorable + FROM ${staging} + ORDER BY seq + `).run(meta.id) + this.db.prepare(` + UPDATE sessions SET + version = ?, + created_at = ?, + cwd = ?, + parent_session = ?, + seed_length = ?, + origin = ?, + delegation_depth = ?, + agent_preset = ?, + revision = revision + 1 + WHERE id = ? + `).run( + meta.version, + meta.createdAt, + meta.cwd ?? null, + meta.parentSession ?? null, + meta.seedLength ?? null, + meta.origin ?? null, + meta.delegationDepth ?? null, + meta.agentPreset ?? null, + meta.id, + ) + this.db.exec('COMMIT') + began = false + } catch (error: unknown) { + if (began) this.db.exec('ROLLBACK') + throw error + } + } finally { + this.db.exec(`DROP TABLE IF EXISTS ${staging}`) + } + } + /** List all materialized sessions' metadata (every row is a materialized session). */ async list(signal?: AbortSignal): Promise { signal?.throwIfAborted() @@ -346,7 +514,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers .prepare('SELECT * FROM sessions') .all() as unknown as SessionRow[] signal?.throwIfAborted() - return rows.map(rowToMeta) + return rows.map(row => decodeStoredSessionHeader(rowToStoredMeta(row), SessionId(row.id))) } /** List metadata with a source-qualified monotonic revision per session. */ @@ -357,7 +525,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] signal?.throwIfAborted() return rows.map(row => ({ - header: rowToMeta(row), + header: decodeStoredSessionHeader(rowToStoredMeta(row), SessionId(row.id)), revision: SessionPersistenceRevision( `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, ), diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index c7402d7d4b..e436486c09 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -193,6 +193,26 @@ export function rowToMeta(row: SessionRow): SessionHeader { } } +/** + * Reconstruct parsed logical header JSON without applying the current Session + * format type. The versioned decoder owns structural migration and validation. + * @param row - stored session row. + * @returns logical header fields represented by the physical schema. + */ +export function rowToStoredMeta(row: SessionRow): unknown { + return { + version: row.version, + id: row.id, + createdAt: row.created_at, + ...row.cwd !== null ? { cwd: row.cwd } : {}, + ...row.parent_session !== null ? { parentSession: row.parent_session } : {}, + ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, + ...row.origin !== null ? { origin: row.origin } : {}, + ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, + ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, + } +} + /** * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). * @param row - the `events` table row; `data` and the surface columns hold JSON text. @@ -225,7 +245,7 @@ export function rowToEvent(row: EventRow): SessionEvent { * * @param rows - one session's event rows, ordered by seq ascending. * @param base - the seq the first row is expected to carry; `0` for a whole - * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`). + * log, or the requested `fromSeq` for a seekable suffix read. * @returns the preserved event prefix, plus `tornFrom` — the seq the physical * delete starts at — when a torn tail exists. */ diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 8f0c49e71f..83576b962b 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,5 +1,5 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -9,10 +9,12 @@ import { DatabaseSync } from 'node:sqlite' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' +import { SessionPersistenceRevisionConflictError } from '@deepseek-ai/dsh-session-persistence' import { openDatabase, rowToEvent, rowToMeta, + rowToStoredMeta, scanRows, SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, type EventRow, @@ -48,6 +50,10 @@ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () = return { ctx, dispose: () => fiber.dispose() } } +async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable { + for (const event of events) yield structuredClone(event) +} + // Run the same backend-agnostic contract as JSONL to pin identical semantics. runPersistenceContract('sqlite', async () => { const ctx = new Context() @@ -161,6 +167,53 @@ describe('scanRows', () => { }) describe('rowToMeta', () => { + it('projects every optional physical header field without current-format decoding', () => { + const row = { + id: 'stored-meta', + version: 7, + created_at: 1, + cwd: '/work', + parent_session: 'parent', + seed_length: 3, + origin: 'subagent' as const, + incarnation: 'stored-meta', + revision: 1, + delegation_depth: 2, + agent_preset: 'minimal', + } + expect(rowToStoredMeta(row)).toEqual({ + version: 7, + id: 'stored-meta', + createdAt: 1, + cwd: '/work', + parentSession: 'parent', + seedLength: 3, + origin: 'subagent', + delegationDepth: 2, + agentPreset: 'minimal', + }) + expect(rowToStoredMeta({ + ...row, + cwd: null, + parent_session: null, + seed_length: null, + origin: null, + delegation_depth: null, + agent_preset: null, + })).toEqual({ version: 7, id: 'stored-meta', createdAt: 1 }) + expect(rowToMeta(row)).toEqual({ + version: 7, + id: 'stored-meta', + createdAt: 1, + cwd: '/work', + parentSession: 'parent', + seedLength: 3, + origin: 'subagent', + delegationDepth: 2, + agentPreset: 'minimal', + }) + }) + it('restores optional origin metadata', () => { expect(rowToMeta({ id: 'with-origin', @@ -597,19 +650,175 @@ describe('SqliteSessionPersistence: durability and crash semantics', () => { await b.dispose() }) - it('binds a full stored prefix to the same revision as a lightweight read', async () => { + it('binds a stored source to the same revision as a lightweight read', async () => { const b = await backend() const m = meta('stored-prefix-revision') await b.ctx.sessionPersistence.create(m) await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence - const stored = await persistence.loadStored(m.id) + const stored = await persistence.openStored(m.id) expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() await b.dispose() }) + it('rejects revision-bound full and suffix readers after the row changes or disappears', async () => { + const { ctx, dispose } = await backend() + const m = meta('stored-reader-conflict') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as SqliteSessionPersistence + const changed = await persistence.openStored(m.id) + if (changed === undefined) throw new Error('test session must be materialized') + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } }, + ]) + const changedRead = changed.readEvents() + const changedCompletion = changedRead.completed.catch((error: unknown) => error) + await expect((async () => { for await (const _event of changedRead.events) { /* consume */ } })()) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await expect(changedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError) + + const removed = await persistence.openStored(m.id) + if (removed === undefined) throw new Error('test session must remain materialized') + const internals = persistence as unknown as { db: DatabaseSync } + internals.db.prepare('DELETE FROM sessions WHERE id = ?').run(m.id) + const removedRead = removed.readEvents({ fromSeq: 1 }) + const removedCompletion = removedRead.completed.catch((error: unknown) => error) + await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })()) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await expect(removedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await dispose() + }) + + it('rolls back a suffix snapshot when its SQL read fails and reports absent direct snapshots', async () => { + const { ctx, dispose } = await backend() + const persistence = ctx.sessionPersistence as SqliteSessionPersistence + const internals = persistence as unknown as { + db: DatabaseSync + readPrefix(id: SessionId): Promise + readSuffix(id: SessionId, fromSeq: number): Promise + } + expect(await internals.readPrefix(SessionId('missing-prefix'))).toBeUndefined() + expect(await internals.readSuffix(SessionId('missing-suffix'), 1)).toBeUndefined() + + const m = meta('suffix-rollback') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const prepare = internals.db.prepare.bind(internals.db) + const spy = vi.spyOn(internals.db, 'prepare').mockImplementation((sql) => { + if (sql.includes('seq >= ?')) throw new Error('simulated suffix SELECT failure') + return prepare(sql) + }) + await expect(internals.readSuffix(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure') + spy.mockRestore() + expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + .toBe(oneTurnLog().length) + await dispose() + }) + + it('atomically replaces one exact revision and rejects a stale replacement', async () => { + const b = await backend() + const m = meta('format-replace') + const original = [ + ...oneTurnLog(), + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, original) + const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog())) + const replaced = await persistence.openStored(m.id) + if (replaced === undefined) throw new Error('replacement must preserve the session') + expect(replaced.revision).not.toBe(source.revision) + expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog()) + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents(original)), + ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog()) + await b.dispose() + }) + + it('rejects replacement identity changes before and during the transaction', async () => { + const first = await backend() + const m = meta('format-replace-identity', '/work') + await first.ctx.sessionPersistence.create(m) + await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = first.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await expect(persistence.replaceStored( + source.revision, + { ...m, cwd: '/other' }, + replacementEvents(oneTurnLog()), + )).rejects.toThrow(/changes its stored identity/) + + const internals = persistence as unknown as { db: DatabaseSync } + const changesDuringStaging = (async function* (): AsyncIterable { + yield* oneTurnLog() + internals.db.prepare('UPDATE sessions SET cwd = ? WHERE id = ?').run('/raced', m.id) + })() + await expect(persistence.replaceStored(source.revision, m, changesDuringStaging)) + .rejects.toThrow(/changes its stored identity/) + expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + .toBe(oneTurnLog().length) + await first.dispose() + }) + + it('rejects a revision change that occurs while replacement events are staged', async () => { + const mounted = await backend() + const m = meta('format-replace-staging-race', '/work') + await mounted.ctx.sessionPersistence.create(m) + await mounted.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = mounted.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const internals = persistence as unknown as { db: DatabaseSync } + const changesDuringStaging = (async function* (): AsyncIterable { + yield* oneTurnLog() + internals.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(m.id) + })() + + await expect(persistence.replaceStored(source.revision, m, changesDuringStaging)) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + .toBe(oneTurnLog().length) + await mounted.dispose() + }) + + it('rolls back the complete replacement when the transaction fails after it begins', async () => { + const b = await backend() + const m = meta('format-replace-rollback') + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const db = (persistence as unknown as { db: DatabaseSync }).db + db.exec(` + CREATE TEMP TRIGGER fail_format_replace + BEFORE UPDATE ON sessions + BEGIN + SELECT RAISE(ABORT, 'simulated format replacement failure'); + END + `) + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents([])), + ).rejects.toThrow(/simulated format replacement failure/) + db.exec('DROP TRIGGER fail_format_replace') + + expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog()) + await b.dispose() + }) + it('changes revisions when a deleted session id is materialized again in the same database', async () => { const path = await freshDbPath() const m = meta('recreated-revision') diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index eb5f9714c4..0fa2bf1df9 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -9,15 +9,22 @@ import { Context } from '@deepseek-ai/cordis' import { adoptSessionEvent, interruptedTurnClosers, - KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, snapshotJsonValue, - snapshotSessionEvent, } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { SessionInspection, SessionLocation } from './index.ts' +import type { SessionInspection } from './index.ts' +import { + decodeStoredSession, + SessionFormatUnsupportedError, +} from './format-decoder.ts' +import type { + DecodedSession, + StoredSessionSource, +} from './format-decoder.ts' +import { SessionPersistenceRevisionConflictError } from './revision.ts' import type { SessionPersistenceRevision } from './revision.ts' import { observeQueuedAbort, SessionPreparations } from './preparations.ts' import type { SessionPreparationReservation } from './preparations.ts' @@ -44,42 +51,6 @@ export class SessionPersistenceCorruptionError extends Error { } } -/** - * The stored log is intact but this runtime cannot faithfully interpret it: - * the header carries an unsupported format version, or an event's type is - * unknown to this build and the event is not marked ignorable. Distinct from - * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log - * remains readable at {@link location} when the backend keeps one artifact - * per session. - */ -export class SessionFormatUnsupportedError extends Error { - /** - * @param message - stable reason the log cannot be interpreted, already - * including the raw-log path when one exists. - * @param location - the backend's artifact location, when one exists. - */ - constructor(message: string, readonly location?: SessionLocation) { - super(message) - this.name = 'SessionFormatUnsupportedError' - } -} - -/** - * Direction-aware refusal text for a stored session whose format version this - * build does not read. Shared by the coordinator's load-time check and by - * backends that must refuse BEFORE decoding version-dependent structure (a - * future format may not satisfy today's structural checks at all, and the - * user must see "upgrade the harness", never "corrupt"). - * @param id - the stored session id, for message context. - * @param version - the stored format version. - * @returns the stable refusal text, without a raw-log path suffix. - */ -export function sessionFormatVersionRefusal(id: string, version: number): string { - return version > SESSION_FORMAT_VERSION - ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` - : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` -} - /** Coordinator policy supplied by a concrete persistence backend. */ export interface PersistenceCoordinatorOptions { /** Maximum completed unpublished preparations retained for reuse. */ @@ -88,32 +59,6 @@ export interface PersistenceCoordinatorOptions { readonly writeBatchMaxDelayMs: number } -/** - * A stored session's header, valid contiguous event prefix, source-qualified - * revision, and optional opaque torn-tail marker. The revision identifies the - * exact detached prefix. The coordinator only checks marker presence and - * returns its value to {@link PersistenceBackend.commitRepair}; each backend - * owns the marker type. - */ -export interface StoredPrefix { - meta: SessionHeader - events: SessionEvent[] - /** Revision observed for exactly this detached prefix. */ - revision: SessionPersistenceRevision - tornMarker?: TornMarker -} - -/** - * A stored session's header plus the events at or past a requested seq — the - * return shape of the optional seek-capable - * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no - * torn marker: there is nothing to repair. - */ -export interface StoredSuffix { - meta: SessionHeader - events: SessionEvent[] -} - /** * The storage contract between {@link PersistenceCoordinator} and a concrete * backend: the minimal set of durable primitives the orchestration calls. A @@ -121,27 +66,21 @@ export interface StoredSuffix { * coordinator supplies everything else (buffering, serialization, cursors, * adoption, crash repair sequencing, dispose quiescence). * - * @typeParam TornMarker - the backend's opaque torn-tail repair token (see - * {@link StoredPrefix}). The coordinator treats it as fully opaque. + * @typeParam TornMarker - the backend's opaque torn-tail repair token returned + * after a complete event read. The coordinator treats it as fully opaque. */ export interface PersistenceBackend { /** Human-readable backend name, used in the dispose-failure AggregateError. */ readonly name: string /** - * Read a stored prefix by id, scanning every backend storage scope. Returns - * `undefined` if no stored artifact exists. Returned metadata must identify - * `id` before repair or state publication. Used by resume/load, live adoption, - * and — via `!== undefined` — the create-collision probe. The returned - * `tornMarker` is present iff there is a torn tail to truncate. Every header - * and event graph must be fresh, mutually unaliased, and unretained by the - * backend because preparation freezes and publishes them in place. The - * returned revision must identify exactly those values and use the same - * representation as {@link readStoredRevision}. + * Open repeatable access to one stored revision by id, scanning every backend + * storage scope. Returns `undefined` if no artifact exists. Each event reader + * reproduces this revision or rejects when a concurrent writer changed it. * @param id - persisted session id to resolve. * @param signal - optional cancellation for backend read work. */ - loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> + openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> /** * Read the current source-qualified revision for one stored session without @@ -151,30 +90,6 @@ export interface PersistenceBackend { */ readStoredRevision(id: SessionId, signal?: AbortSignal): Promise - /** - * Optional seek-capable suffix read behind the service's `readFrom`: return - * the header plus the stored events with `seq >= fromSeq` without reading - * the whole log. A backend whose medium can address events by seq (SQLite) - * implements this so `readFrom` scales with the suffix; sequential backends - * omit it and the coordinator falls back to {@link loadStored} plus a - * forward skip. Non-mutating (no truncation, no closers). Validation of the - * region strictly below `fromSeq` is limited to seq contiguity — the - * service contract scopes this read to the suffix — unless that suffix - * contains a supported legacy shape whose normalization needs earlier - * message-identity facts, in which case the coordinator falls back - * to the complete stored prefix. - * Unknown-type refusal follows the same suffix scope: a seek-capable - * backend's `readFrom` checks only the returned suffix, while the - * sequential fallback parses the whole artifact and refuses on an unknown - * required event anywhere in it — over-refusal on the sequential side is - * accepted rather than widening the seek read. - * @param id - persisted session id to resolve. - * @param fromSeq - first event seq to include (non-negative safe integer, - * validated by the coordinator before this hook runs). - * @param signal - optional cancellation for backend read work. - */ - loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise - /** * Durably append a CONTIGUOUS batch, lazily materializing the session first * when `!isMaterialized`. The materialize-write and the first event batch MUST @@ -192,20 +107,26 @@ export interface PersistenceBackend { */ commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise + /** + * Atomically replace one exact stored revision with a complete current log. + * The backend checks revision and storage identity in the same exclusive file + * operation or database transaction that commits the replacement. + * @param expectedRevision - exact source revision decoded by the caller. + * @param meta - complete current-format header. + * @param events - complete current-format event stream. + */ + replaceStored( + expectedRevision: SessionPersistenceRevision, + meta: SessionHeader, + events: AsyncIterable, + ): Promise + /** * List all stored (materialized) sessions' metadata. * @param signal - optional cancellation for backend listing work. */ list(signal?: AbortSignal): Promise - /** - * Optional side-effect-free artifact locator, used to point refusal - * diagnostics ({@link SessionFormatUnsupportedError}) at the raw log. - * Backends without one artifact per session omit it or return `undefined`. - * @param meta - the header whose artifact is requested. - */ - locate?(meta: SessionHeader): SessionLocation | undefined - /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -245,6 +166,7 @@ interface PreparedSessionSource { readonly inspection: SessionInspection readonly session: Session readonly revision: SessionPersistenceRevision + readonly sourceVersion: number /** Session length after constructor-owned seed markers were appended. */ readonly sessionLength: number readonly tornMarker: TornMarker | undefined @@ -270,7 +192,7 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio }) } -/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */ +/** Reject obsolete v0 event records before a live writer persists them. */ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void { const legacyType: string = 'request/header-delta' const legacy = events.find(event => event.type === legacyType) @@ -289,287 +211,29 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): } } -/** Return an object record without widening arrays into message payloads. */ -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined -} - -/** Whether a record contains every required key and no key outside the optional extension set. */ -function hasOnlyKeys( - record: Record, - required: readonly string[], - optional: readonly string[] = [], -): boolean { - const allowed = [...required, ...optional] - return Object.keys(record).every(key => allowed.includes(key)) - && required.every(key => Object.hasOwn(record, key)) -} - -type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] - -/** Mint the stable import identity for a message persisted before identities existed. */ -function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { - return `legacy-message:${id}:${seq}` as PersistedMessageId -} - -/** Read a replacement target while leaving malformed surface metadata to the session validator. */ -function replacementStart(event: SessionEvent): number | undefined { - const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) - return op?.['op'] === 'replace' && typeof op['start'] === 'number' - ? op['start'] - : undefined -} - -/** Whether one suffix event needs facts available only from the preceding stored prefix. */ -function needsLegacyPrefix(event: SessionEvent): boolean { - const data = asRecord(event.data) - const legacySteeringType: string = 'steering/message' - if (event.type === legacySteeringType) return true - if (data === undefined) return false - switch (event.type) { - case 'user/message': - return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content') - case 'assistant/message': - return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content') - case 'tool/result': - return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId') - default: - return false +/** Materialize one decoded event read and observe its physical EOF metadata. */ +async function collectDecodedEvents( + read: DecodedSession, +): Promise<{ events: SessionEvent[]; tornMarker: TornMarker | undefined }> { + const events: SessionEvent[] = [] + try { + for await (const event of read.events) events.push(event) + } catch (error: unknown) { + await read.completed.catch(() => undefined) + throw error } + const { tornMarker } = await read.completed + return { events, tornMarker } } -/** Upgrade the removed steering surface event into its current user-message equivalent. */ -function migrateLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { - const legacyType: string = 'steering/message' - if (event.type !== legacyType) return event - const data = asRecord(event.data) - if (data === undefined) { - throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) - } - const wrapped = asRecord(data['message']) - if (wrapped !== undefined && Number.isSafeInteger(data['turn']) - && hasOnlyKeys(data, ['turn', 'message'])) { - return { ...event, type: 'user/message', data: wrapped } as SessionEvent - } - if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) { - throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) - } - const { turn: _turn, ...message } = data +/** Yield an immutable event array as one replacement stream. */ +function eventStream(events: readonly SessionEvent[]): AsyncIterable { return { - ...event, - type: 'user/message', - data: { - ...message, - id: legacyMessageId(id, event.seq), - role: 'user', + [Symbol.asyncIterator]() { + const iterator = events[Symbol.iterator]() + return { next: () => Promise.resolve(iterator.next()) } }, - } as SessionEvent -} - -/** Remove the obsolete trigger after verifying the complete old turn-start envelope. */ -function migrateLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { - if (event.type !== 'turn/start') return event - const data = asRecord(event.data) - if (data === undefined || !Object.hasOwn(data, 'trigger')) return event - const trigger = asRecord(data['trigger']) - if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 - || !hasOnlyKeys(data, ['turn', 'trigger']) - || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { - throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`) } - return { ...event, data: { turn: data['turn'] } } as SessionEvent -} - -/** Upgrade an obsolete turn ending while preserving the latest-master envelope. */ -function migrateLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { - if (event.type !== 'turn/end') return event - const data = asRecord(event.data) - /* v8 ignore next -- a non-record current envelope cannot match a legacy shape. */ - if (data === undefined) return event - const malformed = (): never => { - throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) - } - const reason = asRecord(data['reason']) - if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 - || !hasOnlyKeys(data, ['turn', 'reason']) - || reason === undefined || typeof reason['kind'] !== 'string') return malformed() - - let currentReason: Record | undefined - switch (reason['kind']) { - case 'completed': - case 'blocked': - case 'max-tokens': - case 'interrupted': - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - return event - case 'aborted': - if (Object.hasOwn(reason, 'reason')) return event - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - currentReason = { kind: 'aborted', reason: { kind: 'legacy' } } - break - case 'disposed': - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - currentReason = { kind: 'aborted', reason: { kind: 'disposed' } } - break - case 'error': { - if (Object.hasOwn(reason, 'error')) return event - if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() - const failure = asRecord(reason['failure']) - if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) - && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) - && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' - && (failure['status'] === undefined || typeof failure['status'] === 'number') - && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number') - && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) { - currentReason = { kind: 'error', error: failure } - break - } - const messageKeys = reason['code'] === undefined - ? ['kind', 'step', 'message'] - : ['kind', 'step', 'message', 'code'] - if (!hasOnlyKeys(reason, messageKeys) - || typeof reason['message'] !== 'string' - || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed() - currentReason = { - kind: 'error', - error: { - message: reason['message'], - code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN', - }, - } - break - } - default: - return event - } - - return { - ...event, - data: { - ...data, - reason: currentReason, - }, - } as SessionEvent -} - -/** - * Upgrade one pre-identity message event into the current wrapper shape. - * Current-looking malformed events remain untouched so validation rejects them - * instead of disguising corruption as legacy data. - */ -function migrateLegacyMessageEvent( - event: SessionEvent, - id: SessionId, - messageIds: ReadonlyMap, -): SessionEvent { - const data = asRecord(event.data) - if (data === undefined) return event - switch (event.type) { - case 'user/message': { - if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role') - || Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event - return { - ...event, - data: { - ...data, - id: legacyMessageId(id, event.seq), - role: 'user', - }, - } as SessionEvent - } - case 'assistant/message': { - if (Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event - const { content, provenance, ...eventData } = data - return { - ...event, - data: { - ...eventData, - message: { - id: legacyMessageId(id, event.seq), - role: 'assistant', - content, - source: { - ...asRecord(provenance), - kind: 'model', - }, - }, - }, - } as SessionEvent - } - case 'tool/result': { - if (Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content') - || !Object.hasOwn(data, 'isError')) return event - const { callId, content, isError, ...eventData } = data - const inheritedId = replacementStart(event) - return { - ...event, - data: { - ...eventData, - message: { - id: inheritedId === undefined - ? legacyMessageId(id, event.seq) - : messageIds.get(inheritedId), - role: 'user', - content: [{ - type: 'tool-result', - toolCallId: callId, - content, - isError, - }], - source: { - kind: 'tool', - callId, - }, - }, - }, - } as SessionEvent - } - default: - return event - } -} - -/** Read the identified message carried by one validated current event. */ -function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { - const data = asRecord(event.data) - const message = event.type === 'user/message' ? data : asRecord(data?.['message']) - return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined -} - -/** Materialize stored events as upgraded, validated snapshots with immutable messages. */ -function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] { - assertSupportedEvents(events, id) - const messageIds = new Map() - return events.map((event) => { - const migratedStart = migrateLegacyTurnStartEvent(event, id) - const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) - const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) - const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) - const messageId = eventMessageId(snapshot) - if (messageId !== undefined) messageIds.set(snapshot.seq, messageId) - return snapshot - }) -} - -/** Upgrade and validate an exclusively owned backend result without copying it. */ -function adoptStoredEvents(events: SessionEvent[], id: SessionId): SessionEvent[] { - assertSupportedEvents(events, id) - const messageIds = new Map() - for (const [index, event] of events.entries()) { - const migratedStart = migrateLegacyTurnStartEvent(event, id) - const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) - const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) - const adopted = adoptSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) - events[index] = adopted - const messageId = eventMessageId(adopted) - if (messageId !== undefined) messageIds.set(adopted.seq, messageId) - } - return events } /** @@ -650,7 +314,7 @@ export class PersistenceCoordinator { // A persisted artifact under this id (in ANY scope) blocks creation: load/ // resume identify a session by id alone, so a second artifact would make // resume nondeterministic. - if (await this.backend.loadStored(meta.id) !== undefined) { + if (await this.backend.openStored(meta.id) !== undefined) { throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`) } // Pure lazy: record intent only. No artifact until the first append. @@ -821,9 +485,8 @@ export class PersistenceCoordinator { /** * Read the stored events from `fromSeq` onward, detached and non-mutating * (the read-from-seq primitive behind the service's `readFrom`). Runs on - * the same per-id chain as writes; a backend with the seek-capable - * {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix, - * every other backend reads its stored prefix and skips forward here. + * the same per-id chain as writes. The format decoder requests a backend + * suffix only when every selected transform can start at `fromSeq`. * @param id - persisted session to read. * @param fromSeq - first event seq to include; a non-negative safe integer. * @param signal - optional cancellation for queued and backend read work. @@ -843,90 +506,64 @@ export class PersistenceCoordinator { fromSeq: number, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - signal?.throwIfAborted() - if (this.backend.loadStoredFrom !== undefined) { - let suffix: StoredSuffix | undefined + for (;;) { + signal?.throwIfAborted() + const stored = await this.backend.openStored(id, signal) + signal?.throwIfAborted() + if (stored === undefined) throw new Error(`session "${id}" not found`) try { - suffix = await this.backend.loadStoredFrom(id, fromSeq, signal) + const current = decodeStoredSession(stored, id, fromSeq) + const { events } = await collectDecodedEvents(current) + signal?.throwIfAborted() + return { meta: structuredClone(current.meta), events } } catch (error: unknown) { - if (signal?.aborted) signal.throwIfAborted() + signal?.throwIfAborted() + if (error instanceof SessionPersistenceRevisionConflictError) continue throw error } - signal?.throwIfAborted() - if (suffix === undefined) throw new Error(`session "${id}" not found`) - this.assertStoredId(id, suffix.meta) - this.assertVersion(suffix.meta) - if (suffix.events.some(needsLegacyPrefix)) { - const whole = await this.readStoredPrefix(id, signal) - return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } - } - const events = snapshotStoredEvents(suffix.events, id) - this.assertEventsSupported(suffix.meta, events) - return { meta: structuredClone(suffix.meta), events } - } - const whole = await this.readStoredPrefix(id, signal) - // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. - return { meta: whole.meta, events: whole.events.slice(fromSeq) } - } - - /** Read one detached physical prefix without logical recovery or caching. */ - private async readStoredPrefix( - id: SessionId, - signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - signal?.throwIfAborted() - const stored = await this.backend.loadStored(id, signal) - signal?.throwIfAborted() - if (stored === undefined) throw new Error(`session "${id}" not found`) - this.assertStoredId(id, stored.meta) - this.assertVersion(stored.meta) - const events = snapshotStoredEvents(stored.events, id) - this.assertEventsSupported(stored.meta, events) - return { - meta: structuredClone(stored.meta), - events, } } /** Read, repair in memory, validate, and freeze one cold source once. */ private async prepareCore(id: SessionId): Promise> { - const stored = await this.backend.loadStored(id) - if (stored === undefined) throw new Error(`session "${id}" not found`) - try { - const { meta, events, revision, tornMarker } = stored - this.assertStoredId(id, meta) - this.assertVersion(meta) - const storedEvents = adoptStoredEvents(events, id) - this.assertEventsSupported(meta, storedEvents) + for (;;) { + const stored = await this.backend.openStored(id) + if (stored === undefined) throw new Error(`session "${id}" not found`) + try { + const current = decodeStoredSession(stored, id) + const { events: storedEvents, tornMarker } = await collectDecodedEvents(current) - // Preserve complete interrupted events and synthesize only missing closers. - const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) - const balanced = [...storedEvents, ...closers] - const session = this.ctx.sessions.prepare(id, { - seed: balanced, - meta, - seedSource: 'persistence', - }) - const inspection: SessionInspection = Object.freeze({ - meta: session.header, - events: Object.freeze(balanced), - }) - return { - inspection, - session, - revision, - sessionLength: session.events.length, - tornMarker, - closers, + // Preserve complete interrupted events and synthesize only missing closers. + const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) + const balanced = [...storedEvents, ...closers] + const session = this.ctx.sessions.prepare(id, { + seed: balanced, + meta: current.meta, + seedSource: 'persistence', + }) + const inspection: SessionInspection = Object.freeze({ + meta: session.header, + events: Object.freeze(balanced), + }) + return { + inspection, + session, + revision: current.revision, + sourceVersion: current.sourceVersion, + sessionLength: session.events.length, + tornMarker, + closers, + } + } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) continue + // An unsupported format is a refusal over an intact log, not damage — + // surface it unwrapped so callers can point at the raw artifact. + if (error instanceof SessionFormatUnsupportedError) throw error + throw new SessionPersistenceCorruptionError( + `stored session "${id}" failed validation: ${String(error)}`, + { cause: error }, + ) } - } catch (error: unknown) { - // An unsupported format is a refusal over an intact log, not damage — - // surface it unwrapped so callers can point at the raw artifact. - if (error instanceof SessionFormatUnsupportedError) throw error - throw new SessionPersistenceCorruptionError( - `stored session "${id}" failed validation: ${String(error)}`, - { cause: error }, - ) } } @@ -941,6 +578,19 @@ export class PersistenceCoordinator { throw new Error(`session "${id}" already has a live persistence owner`) } if (!await this.isPreparedSourceCurrent(source)) return undefined + if (source.sourceVersion !== SESSION_FORMAT_VERSION) { + try { + await this.backend.replaceStored( + source.revision, + source.inspection.meta, + eventStream(source.inspection.events), + ) + } catch (error: unknown) { + if (!(error instanceof SessionPersistenceRevisionConflictError)) throw error + } + // A commit has a new revision; a conflict names a different source. + return undefined + } if (source.tornMarker !== undefined || source.closers.length > 0) { await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers) // The repair changed the durable revision. Reload the exact committed @@ -1043,44 +693,6 @@ export class PersistenceCoordinator { } } - private assertVersion(meta: SessionHeader): void { - if (meta.version === SESSION_FORMAT_VERSION) return - throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version)) - } - - /** - * Refuse a log containing an event type this build does not know, unless the - * writer marked the event ignorable: an unrecognized required event may - * change how the rest of the log must be interpreted, so silently skipping - * it would reconstruct a wrong session (the envelope contract on - * `SessionEvent.ignorable`). Runs on NORMALIZED events — after - * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes - * this build still reads and rejected the ones it does not, so those keep - * their specific diagnostics. - */ - private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void { - for (const event of events) { - if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue - throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`) - } - } - - /** Build a format refusal that points at the raw artifact when the backend has one. */ - private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError { - const location = this.backend.locate?.(meta) - return new SessionFormatUnsupportedError( - location === undefined ? reason : `${reason} (raw log: ${location.path})`, - location, - ) - } - - /** Reject backend metadata that is not bound to the requested session id. */ - private assertStoredId(id: SessionId, meta: SessionHeader): void { - if (meta.id !== id) { - throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`) - } - } - // --- write path (session/event → flush drain) --- private installWritePath(): void { @@ -1213,11 +825,19 @@ export class PersistenceCoordinator { */ private async seedMatchesPersisted(id: SessionId, seed: readonly SessionEvent[], cursor: number): Promise { if (cursor === 0) return true - const stored = await this.backend.loadStored(id) - /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ - if (stored === undefined) return false - this.assertStoredId(id, stored.meta) - return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor)) + for (;;) { + const stored = await this.backend.openStored(id) + /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ + if (stored === undefined) return false + try { + const current = decodeStoredSession(stored, id) + const { events } = await collectDecodedEvents(current) + return seedCoversPrefix(seed, events.slice(0, cursor)) + } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) continue + throw error + } + } } /** @@ -1271,13 +891,18 @@ export class PersistenceCoordinator { // case 2/3: resolve the id once across storage, then let adoption reject a // cwd mismatch before repair or state publication. - const live = await this.backend.loadStored(id) - if (live !== undefined) { + for (;;) { + const live = await this.backend.openStored(id) + if (live === undefined) break // Do NOT route through cold preparation: that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. - await this.adoptLivePrefix(session, seed, live) - return + try { + if (await this.adoptLivePrefix(session, seed, live)) return + } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) continue + throw error + } } // case 4: a genuinely new session. Register its meta (lazy), then persist its @@ -1298,28 +923,39 @@ export class PersistenceCoordinator { * the live Session is still the authority), bind ownership, and persist the * live suffix that was ahead of the stored prefix. */ - private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { - const { meta, events, tornMarker } = stored - this.assertStoredId(session.header.id, meta) - if (meta.cwd !== session.header.cwd) { - throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + private async adoptLivePrefix( + session: Session, + seed: readonly SessionEvent[], + stored: StoredSessionSource, + ): Promise { + const current = decodeStoredSession(stored, session.header.id) + if (current.meta.cwd !== session.header.cwd) { + throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(current.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } - this.assertVersion(meta) - const storedEvents = snapshotStoredEvents(events, session.header.id) - this.assertEventsSupported(meta, storedEvents) + const { events: storedEvents, tornMarker } = await collectDecodedEvents(current) if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } + if (current.sourceVersion !== SESSION_FORMAT_VERSION) { + await this.backend.replaceStored( + current.revision, + current.meta, + eventStream(storedEvents), + ) + // Reopen after the commit because it produced a new source revision. + return false + } // Truncate-only repair (no closers): the open turn is NOT closed here. - if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) + if (tornMarker !== undefined) await this.backend.commitRepair(current.meta, tornMarker, []) this.states.set(session.header.id, { - meta: { ...meta }, + meta: { ...current.meta }, cursor: storedEvents.length, materialized: true, owner: session, }) const suffix = seed.slice(storedEvents.length) if (suffix.length > 0) await this.appendCore(session.header.id, suffix) + return true } private async flush(session: Session): Promise { diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts new file mode 100644 index 0000000000..2fbc45ccd3 --- /dev/null +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -0,0 +1,480 @@ +/** + * Static Session format decoding from backend-owned JSON records to the + * current durable header and event types. + * @module @deepseek-ai/dsh-session-persistence/format-decoder + */ + +import { + adoptSessionEvent, + KNOWN_SESSION_EVENT_TYPES, + SESSION_FORMAT_VERSION, + Session, + SessionId, + snapshotJsonValue, +} from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { + unversionedFormatCompatibility, +} from './format-v0-compat.ts' +import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' +import type { SessionLocation } from './index.ts' +import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' +import type { SessionPersistenceRevision } from './revision.ts' + +/** Stable facts available to one format step invocation. */ +export interface SessionFormatContext { + /** Session identity read from the source header. */ + readonly sessionId: SessionId +} + +/** One static adjacent-version transform in the durable format decoder. */ +export interface SessionFormatStep { + /** Input Session format version. */ + readonly from: number + /** Output Session format version; must equal `from + 1`. */ + readonly to: number + /** + * Transform and validate the header fields understood by this step. + * @param meta - detached input header for {@link from}. + * @param context - stable session identity. + * @returns detached header JSON carrying {@link to}. + */ + migrateHeader(meta: unknown, context: SessionFormatContext): unknown + /** + * Lazily transform and validate events understood by this step. + * @param events - detached input records in durable sequence order. + * @param context - stable session identity. + * @returns a lazy output stream in the next format. + */ + migrateEvents(events: AsyncIterable, context: SessionFormatContext): AsyncIterable +} + +/** Options for one physical event read. */ +export interface StoredEventReadOptions { + /** First physical event sequence to request. */ + readonly fromSeq?: number +} + +/** Completion metadata produced after a physical event stream reaches EOF. */ +export interface StoredEventReadCompletion { + /** Backend-owned token for a recoverable physical tail. */ + readonly tornMarker?: TornMarker +} + +/** One revision-bound physical event stream. */ +export interface StoredEventRead { + /** Parsed JSON records from the exact source revision. */ + readonly events: AsyncIterable + /** Resolves only after the stream reaches EOF at the same revision. */ + readonly completed: Promise> +} + +/** Repeatable access to one stored header and exact durable revision. */ +export interface StoredSessionSource { + /** Parsed header JSON; format validation belongs to the decoder. */ + readonly meta: unknown + /** Exact backend revision every event read must reproduce or reject. */ + readonly revision: SessionPersistenceRevision + /** Raw artifact location used to enrich unsupported-format diagnostics. */ + readonly location?: SessionLocation + /** + * Open a new event read bound to {@link revision}. A concurrent replacement + * rejects the read instead of returning events from another revision. + * @param options - optional suffix request. + * @returns one independently consumable physical event read. + */ + readEvents(options?: StoredEventReadOptions): StoredEventRead +} + +/** One decoded current-format read bound to an exact stored revision. */ +export interface DecodedSession { + /** Validated current-format header. */ + readonly meta: SessionHeader + /** Version observed before any format step ran. */ + readonly sourceVersion: number + /** Exact backend revision represented by this source. */ + readonly revision: SessionPersistenceRevision + /** Validated current-format events at or past the requested sequence. */ + readonly events: AsyncIterable + /** Completion metadata from the physical read supplying the events. */ + readonly completed: Promise> +} + +/** + * The stored log is intact but this runtime cannot faithfully interpret its + * format version or required event vocabulary. + */ +export class SessionFormatUnsupportedError extends Error { + /** + * @param message - stable refusal reason, including the raw location when available. + * @param location - backend artifact location when one exists. + */ + constructor(message: string, readonly location?: SessionLocation) { + super(message) + this.name = 'SessionFormatUnsupportedError' + } +} + +/** + * Direction-aware refusal text for a stored format version this build cannot + * decode. + * @param id - stored session identity. + * @param version - stored format version. + * @returns stable refusal text without a raw-location suffix. + */ +export function sessionFormatVersionRefusal(id: string, version: number): string { + return version > SESSION_FORMAT_VERSION + ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` +} + +function buildStepIndex(steps: readonly SessionFormatStep[]): ReadonlyMap { + const byFrom = new Map() + for (const step of steps) { + if (!Number.isSafeInteger(step.from) || step.from < 0 || step.to !== step.from + 1) { + throw new TypeError(`Session format step must be an adjacent non-negative version, got v${step.from} -> v${step.to}`) + } + if (byFrom.has(step.from)) { + throw new TypeError(`duplicate Session format step from v${step.from}`) + } + if (step.to > SESSION_FORMAT_VERSION) { + throw new TypeError(`Session format step v${step.from} -> v${step.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`) + } + byFrom.set(step.from, step) + } + for (const first of byFrom.values()) { + for (let version = first.from; version < SESSION_FORMAT_VERSION; version++) { + if (!byFrom.has(version)) { + throw new TypeError( + `Session format registry has an incomplete path from v${first.from} to v${SESSION_FORMAT_VERSION}: missing v${version} -> v${version + 1}`, + ) + } + } + } + return byFrom +} + +const STEP_BY_FROM = buildStepIndex(SESSION_FORMAT_STEPS) + +interface DecodedHeader { + readonly meta: SessionHeader + readonly sourceVersion: number + readonly steps: readonly SessionFormatStep[] + readonly unversionedCompatibility?: UnversionedFormatCompatibility +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +interface StoredHeaderSource { + readonly meta: unknown + readonly location?: SessionLocation +} + +function unsupported( + source: StoredHeaderSource, + reason: string, +): SessionFormatUnsupportedError { + const location = source.location + return new SessionFormatUnsupportedError( + location === undefined ? reason : `${reason} (raw log: ${location.path})`, + location, + ) +} + +function readSourceHeader( + source: StoredHeaderSource, + expectedId: SessionId, +): { meta: Record; version: number; id: SessionId } { + const snapshot = snapshotJsonValue(source.meta) + const meta = asRecord(snapshot) + if (meta === undefined) throw new Error('stored session header is not a lossless JSON record') + if (!Number.isSafeInteger(meta['version'])) { + throw new Error(`stored session header has invalid format version ${String(meta['version'])}`) + } + const version = meta['version'] as number + if (version > SESSION_FORMAT_VERSION) { + throw unsupported(source, sessionFormatVersionRefusal(String(meta['id']), version)) + } + if (typeof meta['id'] !== 'string') throw new Error('stored session header has no string id') + const id = SessionId(meta['id']) + if (id !== expectedId) { + throw new Error(`stored session identity mismatch: requested "${expectedId}", header contains "${id}"`) + } + return { meta, version, id } +} + +function planSteps( + source: StoredHeaderSource, + id: SessionId, + fromVersion: number, +): readonly SessionFormatStep[] { + const steps: SessionFormatStep[] = [] + for (let version = fromVersion; version < SESSION_FORMAT_VERSION; version++) { + const step = STEP_BY_FROM.get(version) + if (step === undefined) { + throw unsupported( + source, + `session "${id}" uses log format v${fromVersion}, older than the supported v${SESSION_FORMAT_VERSION}, and this build has no upgrade path to it: missing v${version} -> v${version + 1}`, + ) + } + steps.push(step) + } + return steps +} + +function decodeHeader( + source: StoredHeaderSource, + expectedId: SessionId, +): DecodedHeader { + const stored = readSourceHeader(source, expectedId) + const steps = planSteps(source, stored.id, stored.version) + let meta: unknown = stored.meta + for (const step of steps) { + const context: SessionFormatContext = { sessionId: stored.id } + try { + meta = snapshotJsonValue(step.migrateHeader(meta, context)) + } catch (error: unknown) { + throw new Error( + `session "${stored.id}" header migration v${step.from} -> v${step.to} failed`, + { cause: error }, + ) + } + const record = asRecord(meta) + const actual = record?.['version'] + if (actual !== step.to) { + throw new Error(`Session format step v${step.from} -> v${step.to} returned header version ${String(actual)}`) + } + if (record === undefined + || record['id'] !== stored.id + || record['cwd'] !== stored.meta['cwd']) { + throw new Error(`Session format step v${step.from} -> v${step.to} changed session storage identity`) + } + } + const current = Session.create(stored.id, undefined, meta as SessionHeader).header + const compatibility = unversionedFormatCompatibility(stored.version) + return { + meta: current, + sourceVersion: stored.version, + steps, + ...(compatibility === undefined ? {} : { unversionedCompatibility: compatibility }), + } +} + +/** + * Decode one stored header without opening its event log. Listing uses the + * same static format path as full Session reads. + * @param meta - parsed backend header JSON. + * @param expectedId - identity selected by the backend or caller. + * @param location - optional raw artifact location for refusal diagnostics. + * @returns the validated current-format header. + */ +export function decodeStoredSessionHeader( + meta: unknown, + expectedId: SessionId, + location?: SessionLocation, +): SessionHeader { + return decodeHeader({ meta, ...location === undefined ? {} : { location } }, expectedId).meta +} + +function assertCurrentEnvelope(value: unknown, id: SessionId): SessionEvent { + const snapshot = snapshotJsonValue(value) + const event = asRecord(snapshot) + if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) + if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) + if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { + throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) + } + if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) + } + if (!Object.hasOwn(event, 'data')) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) + } + return event as unknown as SessionEvent +} + +function assertCurrentEventSupported( + source: StoredSessionSource, + meta: SessionHeader, + event: SessionEvent, +): void { + const legacyType: string = 'request/header-delta' + if (event.type === legacyType) { + throw new Error(`session "${meta.id}" contains unsupported legacy request/header-delta event at seq ${event.seq}`) + } + const legacyModeType: string = 'mode/set' + if (event.type === legacyModeType) { + throw new Error(`session "${meta.id}" contains unsupported legacy mode/set event at seq ${event.seq}`) + } + if (event.type === 'request/header' + && (event.data as { reason?: string }).reason === 'fallback') { + throw new Error(`session "${meta.id}" contains unsupported legacy request/header reason "fallback" at seq ${event.seq}`) + } + if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) return + throw unsupported( + source, + `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, + ) +} + +async function* decodeCurrentEvents( + source: StoredSessionSource, + meta: SessionHeader, + events: AsyncIterable, + expectedSeq: number, +): AsyncIterable { + let nextSeq = expectedSeq + for await (const raw of events) { + const event = assertCurrentEnvelope(raw, meta.id) + if (event.seq !== nextSeq) { + throw new Error(`session "${meta.id}" event seq mismatch: expected ${nextSeq}, got ${event.seq}`) + } + const current = adoptSessionEvent(event) + assertCurrentEventSupported(source, meta, current) + nextSeq += 1 + yield current + } +} + +function transformEvents( + events: AsyncIterable, + steps: readonly SessionFormatStep[], + id: SessionId, +): AsyncIterable { + let transformed = events + for (const step of steps) { + const input = transformed + const context: SessionFormatContext = { sessionId: id } + transformed = (async function* (): AsyncIterable { + try { + yield* step.migrateEvents(input, context) + } catch (error: unknown) { + throw new Error( + `session "${id}" event migration v${step.from} -> v${step.to} failed`, + { cause: error }, + ) + } + })() + } + return transformed +} + +async function* snapshotStoredEvents( + events: AsyncIterable, + id: SessionId, +): AsyncIterable { + for await (const event of events) { + const snapshot = snapshotJsonValue(event) + if (snapshot === undefined) { + throw new Error(`session "${id}" contains an event that is not losslessly JSON-serializable`) + } + yield snapshot + } +} + +function deferred(): { + readonly promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +function decodedRead( + source: StoredSessionSource, + header: DecodedHeader, + requestedFromSeq: number, +): { + readonly events: AsyncIterable + readonly completed: Promise> +} { + const completion = deferred>() + const migrating = header.steps.length > 0 + const compatibility = header.unversionedCompatibility + let physical: StoredEventRead | undefined + + const events = (async function* (): AsyncIterable { + try { + let physicalFromSeq = migrating ? 0 : requestedFromSeq + physical = source.readEvents({ fromSeq: physicalFromSeq }) + void physical.completed.catch(() => undefined) + let raw: AsyncIterable = physical.events + let physicalCompletion: StoredEventReadCompletion | undefined + + if (!migrating && requestedFromSeq > 0 && compatibility !== undefined) { + const suffix: unknown[] = [] + for await (const value of raw) suffix.push(value) + physicalCompletion = await physical.completed + if (suffix.some(value => compatibility.requiresPrefix(value))) { + physicalFromSeq = 0 + physical = source.readEvents({ fromSeq: 0 }) + void physical.completed.catch(() => undefined) + raw = physical.events + physicalCompletion = undefined + } else { + raw = (async function* () { + for (const value of suffix) yield await Promise.resolve(value) + })() + } + } + + const storedEvents = snapshotStoredEvents(raw, header.meta.id) + const canonicalEvents = compatibility === undefined + ? storedEvents + : compatibility.canonicalizeEvents(storedEvents, header.meta.id) + const transformed = transformEvents( + canonicalEvents, + header.steps, + header.meta.id, + ) + const current = decodeCurrentEvents(source, header.meta, transformed, physicalFromSeq) + for await (const event of current) { + if (event.seq >= requestedFromSeq) yield event + } + completion.resolve(physicalCompletion ?? await physical.completed) + } catch (error: unknown) { + completion.reject(error) + throw error + } + })() + + return { events, completed: completion.promise } +} + +/** + * Decode one backend source through the static adjacent-version + * steps and the current header/event validators. Format selection is complete + * before any consumer-specific recovery runs. + * @param source - backend-owned header, revision, and event reader factory. + * @param expectedId - session identity selected by the caller. + * @param fromSeq - first current-format event sequence to return. + * @returns one decoded current-format stream bound to the stored revision. + */ +export function decodeStoredSession( + source: StoredSessionSource, + expectedId: SessionId, + fromSeq = 0, +): DecodedSession { + if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) { + throw new TypeError(`stored event fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`) + } + const header = decodeHeader(source, expectedId) + const read = decodedRead(source, header, fromSeq) + return { + meta: header.meta, + sourceVersion: header.sourceVersion, + revision: source.revision, + events: read.events, + completed: read.completed, + } +} diff --git a/packages/session/session-persistence/src/format-migrations/index.ts b/packages/session/session-persistence/src/format-migrations/index.ts new file mode 100644 index 0000000000..ace5670d45 --- /dev/null +++ b/packages/session/session-persistence/src/format-migrations/index.ts @@ -0,0 +1,6 @@ +/** Static adjacent-version Session format steps shipped by this build. */ + +import type { SessionFormatStep } from '../format-decoder.ts' + +/** Ordered durable format steps; format v0 is current, so the chain is empty. */ +export const SESSION_FORMAT_STEPS: readonly SessionFormatStep[] = Object.freeze([]) diff --git a/packages/session/session-persistence/src/format-v0-compat.ts b/packages/session/session-persistence/src/format-v0-compat.ts new file mode 100644 index 0000000000..760e8db8ce --- /dev/null +++ b/packages/session/session-persistence/src/format-v0-compat.ts @@ -0,0 +1,293 @@ +/** + * Same-version normalization for durable format-v0 Session records. + * @module @deepseek-ai/dsh-session-persistence/format-v0-compat + */ + +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' + +/** One format-specific normalizer selected before adjacent-version migrations. */ +export interface UnversionedFormatCompatibility { + /** Header version whose historical records require this normalizer. */ + readonly version: number + /** + * Whether converting one suffix record requires facts from earlier events. + * @param value - parsed event JSON from a suffix read. + * @returns whether the decoder must reopen the complete event stream. + */ + requiresPrefix(value: unknown): boolean + /** + * Convert recognized historical records into the canonical representation + * carrying the same version number. + * @param events - parsed event JSON in durable sequence order. + * @param sessionId - identity read from the stored header. + * @returns a lazy stream in the canonical representation for {@link version}. + */ + canonicalizeEvents(events: AsyncIterable, sessionId: SessionId): AsyncIterable +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +function hasOnlyKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = [...required, ...optional] + return Object.keys(record).every(key => allowed.includes(key)) + && required.every(key => Object.hasOwn(record, key)) +} + +type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] + +function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { + return `legacy-message:${id}:${seq}` as PersistedMessageId +} + +function replacementStart(event: SessionEvent): number | undefined { + const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) + return op?.['op'] === 'replace' && typeof op['start'] === 'number' + ? op['start'] + : undefined +} + +function requiresV0Prefix(value: unknown): boolean { + const event = asRecord(value) + if (event === undefined) return false + const data = asRecord(event['data']) + if (event['type'] === 'steering/message') return true + if (data === undefined) return false + switch (event['type']) { + case 'user/message': + return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content') + case 'assistant/message': + return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content') + case 'tool/result': + return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId') + default: + return false + } +} + +function readV0Event(value: unknown, id: SessionId): SessionEvent { + const event = asRecord(value) + if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) + if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) + if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { + throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) + } + if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) + } + if (!Object.hasOwn(event, 'data')) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) + } + return event as unknown as SessionEvent +} + +function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { + const legacyType: string = 'steering/message' + if (event.type !== legacyType) return event + const data = asRecord(event.data) + if (data === undefined) { + throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) + } + const wrapped = asRecord(data['message']) + if (wrapped !== undefined && Number.isSafeInteger(data['turn']) + && hasOnlyKeys(data, ['turn', 'message'])) { + return { ...event, type: 'user/message', data: wrapped } as SessionEvent + } + if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) { + throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) + } + const { turn: _turn, ...message } = data + return { + ...event, + type: 'user/message', + data: { ...message, id: legacyMessageId(id, event.seq), role: 'user' }, + } as SessionEvent +} + +function canonicalizeLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { + if (event.type !== 'turn/start') return event + const data = asRecord(event.data) + if (data === undefined || !Object.hasOwn(data, 'trigger')) return event + const trigger = asRecord(data['trigger']) + if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 + || !hasOnlyKeys(data, ['turn', 'trigger']) + || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { + throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`) + } + return { ...event, data: { turn: data['turn'] } } as SessionEvent +} + +function canonicalizeLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { + if (event.type !== 'turn/end') return event + const data = asRecord(event.data) + if (data === undefined) return event + const malformed = (): never => { + throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) + } + const reason = asRecord(data['reason']) + if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 + || !hasOnlyKeys(data, ['turn', 'reason']) + || reason === undefined || typeof reason['kind'] !== 'string') return malformed() + + let currentReason: Record | undefined + switch (reason['kind']) { + case 'completed': + case 'blocked': + case 'max-tokens': + case 'interrupted': + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + return event + case 'aborted': + if (Object.hasOwn(reason, 'reason')) return event + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + currentReason = { kind: 'aborted', reason: { kind: 'legacy' } } + break + case 'disposed': + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + currentReason = { kind: 'aborted', reason: { kind: 'disposed' } } + break + case 'error': { + if (Object.hasOwn(reason, 'error')) return event + if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() + const failure = asRecord(reason['failure']) + if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) + && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) + && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' + && (failure['status'] === undefined || typeof failure['status'] === 'number') + && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number') + && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) { + currentReason = { kind: 'error', error: failure } + break + } + const messageKeys = reason['code'] === undefined + ? ['kind', 'step', 'message'] + : ['kind', 'step', 'message', 'code'] + if (!hasOnlyKeys(reason, messageKeys) + || typeof reason['message'] !== 'string' + || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed() + currentReason = { + kind: 'error', + error: { + message: reason['message'], + code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN', + }, + } + break + } + default: + return event + } + return { ...event, data: { ...data, reason: currentReason } } as SessionEvent +} + +function canonicalizeLegacyMessageEvent( + event: SessionEvent, + id: SessionId, + messageIds: ReadonlyMap, +): SessionEvent { + const data = asRecord(event.data) + if (data === undefined) return event + switch (event.type) { + case 'user/message': + if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role') + || Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event + return { ...event, data: { ...data, id: legacyMessageId(id, event.seq), role: 'user' } } as SessionEvent + case 'assistant/message': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event + const { content, provenance, ...eventData } = data + return { + ...event, + data: { + ...eventData, + message: { + id: legacyMessageId(id, event.seq), + role: 'assistant', + content, + source: { ...asRecord(provenance), kind: 'model' }, + }, + }, + } as SessionEvent + } + case 'tool/result': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content') + || !Object.hasOwn(data, 'isError')) return event + const { callId, content, isError, ...eventData } = data + const inheritedId = replacementStart(event) + return { + ...event, + data: { + ...eventData, + message: { + id: inheritedId === undefined ? legacyMessageId(id, event.seq) : messageIds.get(inheritedId), + role: 'user', + content: [{ type: 'tool-result', toolCallId: callId, content, isError }], + source: { kind: 'tool', callId }, + }, + }, + } as SessionEvent + } + default: + return event + } +} + +function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { + const data = asRecord(event.data) + const message = event.type === 'user/message' ? data : asRecord(data?.['message']) + return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined +} + +async function* canonicalizeV0Events( + events: AsyncIterable, + id: SessionId, +): AsyncIterable { + const messageIds = new Map() + for await (const value of events) { + const event = readV0Event(value, id) + const turnStart = canonicalizeLegacyTurnStartEvent(event, id) + const turnEnd = canonicalizeLegacyTurnEndEvent(turnStart, id) + const steering = canonicalizeLegacySteeringEvent(turnEnd, id) + const canonical = canonicalizeLegacyMessageEvent(steering, id, messageIds) + const messageId = eventMessageId(canonical) + if (messageId !== undefined) messageIds.set(canonical.seq, messageId) + yield canonical + } +} + +/** + * Durable v0 includes first-party records whose structural changes were not + * accompanied by a format-version change. Their headers cannot select an + * adjacent-version migration, so this exact legacy recognition runs before + * any v0-to-v1 step and produces canonical v0 without changing the version. + * It remains necessary while v0 is current and whenever v0 is an upgrade + * source. Normalization alone is read-only; a selected versioned migration + * causes the canonicalized events to participate in atomic replacement. + */ +const V0_UNVERSIONED_FORMAT_COMPATIBILITY: UnversionedFormatCompatibility = Object.freeze({ + version: 0, + requiresPrefix: requiresV0Prefix, + canonicalizeEvents: canonicalizeV0Events, +}) + +/** + * Select same-version compatibility for one stored header version. + * @param version - format version read from the stored header. + * @returns the static normalizer for that version, if one is required. + */ +export function unversionedFormatCompatibility( + version: number, +): UnversionedFormatCompatibility | undefined { + return version === V0_UNVERSIONED_FORMAT_COMPATIBILITY.version + ? V0_UNVERSIONED_FORMAT_COMPATIBILITY + : undefined +} diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index d579dc46d9..fafb17e875 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -12,7 +12,7 @@ import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' -export { SessionPersistenceRevision } from './revision.ts' +export { SessionPersistenceRevision, SessionPersistenceRevisionConflictError } from './revision.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { @@ -46,17 +46,17 @@ export { DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, - SessionFormatUnsupportedError, SessionPersistenceCorruptionError, - sessionFormatVersionRefusal, } from './coordinator.ts' export type { PersistenceBackend, PersistenceCoordinatorOptions, - StoredPrefix, - StoredSuffix, } from './coordinator.ts' - +export { + decodeStoredSessionHeader, + SessionFormatUnsupportedError, + sessionFormatVersionRefusal, +} from './format-decoder.ts' declare module '@deepseek-ai/cordis' { interface Context { sessionPersistence: SessionPersistence @@ -241,3 +241,12 @@ export abstract class SessionPersistence extends Service { } export default SessionPersistence + +export type { + SessionFormatContext, + SessionFormatStep, + StoredEventRead, + StoredEventReadCompletion, + StoredEventReadOptions, + StoredSessionSource, +} from './format-decoder.ts' diff --git a/packages/session/session-persistence/src/revision.ts b/packages/session/session-persistence/src/revision.ts index cb037ffafc..36a79291b3 100644 --- a/packages/session/session-persistence/src/revision.ts +++ b/packages/session/session-persistence/src/revision.ts @@ -16,3 +16,12 @@ export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> export function SessionPersistenceRevision(value: string): SessionPersistenceRevision { return value as SessionPersistenceRevision } + +/** A repeatable source can no longer reproduce the revision it represents. */ +export class SessionPersistenceRevisionConflictError extends Error { + /** @param message - source identity and expected revision context. */ + constructor(message: string) { + super(message) + this.name = 'SessionPersistenceRevisionConflictError' + } +} diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts new file mode 100644 index 0000000000..e503095bf6 --- /dev/null +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -0,0 +1,889 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + SessionPersistenceRevision, + SessionPersistenceRevisionConflictError, +} from '../src/revision.ts' +import type { + SessionFormatContext, + SessionFormatStep, + StoredEventReadCompletion, + StoredSessionSource, +} from '../src/format-decoder.ts' +import { sessionFormatVersionRefusal } from '../src/format-decoder.ts' +import { unversionedFormatCompatibility } from '../src/format-v0-compat.ts' + +const id = SessionId('format-migration') + +function eventLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +async function collectEvents(events: AsyncIterable): Promise { + const collected: SessionEvent[] = [] + for await (const event of events) collected.push(event) + return collected +} + +async function decodedFailure( + decoded: ReturnType, +): Promise { + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(completionFailure) + expect(streamFailure).toBeInstanceOf(Error) + return streamFailure as Error +} + +function storedSource( + version: number, + events: readonly unknown[], +): { source: StoredSessionSource; reads: number[]; meta: Record } { + const reads: number[] = [] + const meta: Record = { version, id, createdAt: 1 } + return { + meta, + reads, + source: { + meta, + revision: SessionPersistenceRevision(`format-v${version}`), + readEvents({ fromSeq = 0 } = {}) { + reads.push(fromSeq) + return { + events: (async function* (): AsyncIterable { + for (const event of events) { + const seq = typeof event === 'object' && event !== null + ? (event as { seq?: unknown }).seq + : undefined + if (!Number.isSafeInteger(seq) || (seq as number) < 0 || (seq as number) >= fromSeq) { + yield structuredClone(event) + } + } + })(), + completed: Promise.resolve({}), + } + }, + }, + } +} + +function migration( + from: number, + calls: string[], +): SessionFormatStep { + return { + from, + to: from + 1, + migrateHeader(meta) { + calls.push(`header:${from}`) + return { ...(meta as Record), version: from + 1 } + }, + migrateEvents(events) { + return (async function* (): AsyncIterable { + let observedInput = false + for await (const value of events) { + if (!observedInput) { + calls.push(`events:${from}`) + observedInput = true + } + const event = value as SessionEvent + const data = event.data as Record + const migrationPath = Array.isArray(data['migrationPath']) + ? data['migrationPath'] as unknown[] + : [] + yield { + ...event, + data: { + ...data, + [`migratedFrom${from}`]: true, + migrationPath: [...migrationPath, from], + }, + } + } + })() + }, + } +} + +async function configuredDecoder( + currentVersion: number, + migrations: readonly SessionFormatStep[], + calls: string[] = [], +): Promise<{ + decodeStoredSession: typeof import('../src/format-decoder.ts')['decodeStoredSession'] + decodeStoredSessionHeader: typeof import('../src/format-decoder.ts')['decodeStoredSessionHeader'] + validateHeader: ReturnType +}> { + vi.resetModules() + const validateHeader = vi.fn((sessionId: SessionId, _seed: unknown, meta: unknown) => { + calls.push('validate-header') + const record = meta as Record + if (record['version'] !== currentVersion) { + throw new Error(`current header validator received v${String(record['version'])}`) + } + if (record['id'] !== sessionId) throw new Error('current header validator received the wrong id') + if (!Number.isSafeInteger(record['createdAt'])) { + throw new Error('current header validator received invalid createdAt') + } + return { header: Object.freeze(structuredClone(record)) } + }) + vi.doMock('@deepseek-ai/dsh-session', async () => { + const actual = await vi.importActual( + '@deepseek-ai/dsh-session', + ) + return { + ...actual, + SESSION_FORMAT_VERSION: currentVersion, + Session: { create: validateHeader }, + } + }) + vi.doMock('../src/format-migrations/index.ts', () => ({ + SESSION_FORMAT_STEPS: migrations, + })) + const decoder = await import('../src/format-decoder.ts') + return { + decodeStoredSession: decoder.decodeStoredSession, + decodeStoredSessionHeader: decoder.decodeStoredSessionHeader, + validateHeader, + } +} + +afterEach(() => { + vi.doUnmock('@deepseek-ai/dsh-session') + vi.doUnmock('../src/format-migrations/index.ts') + vi.resetModules() +}) + +describe('versioned Session format decoder', { concurrent: false }, () => { + it('describes both unsupported format directions', () => { + expect(sessionFormatVersionRefusal(id, 1)).toContain('newer harness') + expect(sessionFormatVersionRefusal(id, -1)).toContain('older than the supported') + }) + + it('runs a single migration lazily and reads the complete old log before slicing', async () => { + const calls: string[] = [] + const step = migration(0, calls) + const { decodeStoredSession, validateHeader } = await configuredDecoder(1, [step], calls) + const originalEvents = eventLog() + const originalSnapshot = structuredClone(originalEvents) + const stored = storedSource(0, originalEvents) + + const decoded = decodeStoredSession(stored.source, id, 1) + expect(decoded.sourceVersion).toBe(0) + expect(decoded.meta.version).toBe(1) + expect(calls).toEqual(['header:0', 'validate-header']) + expect(stored.reads).toEqual([]) + + const migrated = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([0]) + expect(calls).toEqual(['header:0', 'validate-header', 'events:0']) + expect(migrated).toEqual([ + { + ...originalEvents[1], + data: { ...originalEvents[1]?.data, migratedFrom0: true, migrationPath: [0] }, + }, + ]) + expect(originalEvents).toEqual(originalSnapshot) + expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 }) + expect(validateHeader).toHaveBeenCalledOnce() + }) + + it('lets an old-format suffix migration use facts from events before fromSeq', async () => { + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => (async function* (): AsyncIterable { + let previousSeq: number | undefined + for await (const value of events) { + const event = value as SessionEvent + yield previousSeq === undefined + ? event + : { ...event, data: { ...event.data, previousSeq } } + previousSeq = event.seq + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const stored = storedSource(0, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([0]) + expect(events).toEqual([{ + ...eventLog()[1], + data: { ...eventLog()[1]?.data, previousSeq: 0 }, + }]) + }) + + it('streams migrated events with backpressure instead of buffering the complete log', async () => { + const releaseTail = Promise.withResolvers() + const physicalCompletion = Promise.withResolvers>() + const reads: number[] = [] + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('streaming-source'), + readEvents({ fromSeq = 0 } = {}) { + reads.push(fromSeq) + return { + events: (async function* (): AsyncIterable { + try { + yield structuredClone(eventLog()[0]) + await releaseTail.promise + yield structuredClone(eventLog()[1]) + physicalCompletion.resolve({}) + } catch (error: unknown) { + physicalCompletion.reject(error) + throw error + } + })(), + completed: physicalCompletion.promise, + } + }, + } + const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])]) + const decoded = decodeStoredSession(source, id) + const iterator = decoded.events[Symbol.asyncIterator]() + + const first = await iterator.next() + expect(first).toMatchObject({ done: false, value: { seq: 0 } }) + expect(reads).toEqual([0]) + let completed = false + void decoded.completed.then(() => { completed = true }) + await Promise.resolve() + expect(completed).toBe(false) + + releaseTail.resolve(undefined) + await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { seq: 1 } }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + await expect(decoded.completed).resolves.toEqual({}) + }) + + it('runs a complete multi-step chain before current header and event validation', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + const stored = storedSource(0, eventLog()) + + const decoded = decodeStoredSession(stored.source, id) + expect(decoded.meta.version).toBe(2) + expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) + + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(calls).toEqual([ + 'header:0', + 'header:1', + 'validate-header', + 'events:0', + 'events:1', + ]) + expect(events[0]?.data).toMatchObject({ migratedFrom0: true, migratedFrom1: true }) + expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] }) + }) + + it('plans by version even when registry entries are declared out of order', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(1, calls), migration(0, calls)], + calls, + ) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(calls.slice(0, 3)).toEqual(['header:0', 'header:1', 'validate-header']) + expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] }) + }) + + it('starts a multi-version registry at the source version', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + const stored = storedSource(1, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(calls).toEqual(['header:1', 'validate-header', 'events:1']) + expect(stored.reads).toEqual([0]) + expect(events[0]?.data).toMatchObject({ migrationPath: [1] }) + }) + + it('passes the stable stored identity to every header and event step', async () => { + const contexts: SessionFormatContext[] = [] + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader(meta, context) { + contexts.push(context) + return { ...(meta as Record), version: 1 } + }, + migrateEvents(events, context) { + contexts.push(context) + return events + }, + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + await collectEvents(decoded.events) + await decoded.completed + + expect(contexts).toEqual([{ sessionId: id }, { sessionId: id }]) + }) + + it('migrates and validates a header without requiring an event source', async () => { + const calls: string[] = [] + const { decodeStoredSessionHeader } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + + const header = decodeStoredSessionHeader({ version: 0, id, createdAt: 1 }, id) + + expect(header.version).toBe(2) + expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) + }) + + it('applies event migration before the current event vocabulary check', async () => { + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => (async function* (): AsyncIterable { + for await (const value of events) { + const event = value as Record + yield { ...event, type: 'turn/start', data: { turn: 1 } } + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const stored = storedSource(0, [ + { type: 'legacy/turn-begin', seq: 0, time: 1, data: { legacyTurn: 1 } }, + ]) + + const decoded = decodeStoredSession(stored.source, id) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(events).toEqual([ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ]) + }) + + it('uses suffix access directly for the current format', async () => { + const { decodeStoredSession } = await configuredDecoder(2, []) + const stored = storedSource(2, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([1]) + expect(events).toEqual(eventLog().slice(1)) + }) + + it('buffers a safe current-v0 suffix once without reopening the prefix', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(0, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + expect(await collectEvents(decoded.events)).toEqual(eventLog().slice(1)) + await expect(decoded.completed).resolves.toEqual({}) + + expect(stored.reads).toEqual([1]) + }) + + it('reopens the complete current-v0 log when a legacy suffix record needs its prefix', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const legacy = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { + type: 'steering/message', + seq: 1, + time: 2, + data: { turn: 1, content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }, + }, + ] + const stored = storedSource(0, legacy) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([1, 0]) + expect(events).toMatchObject([{ type: 'user/message', seq: 1 }]) + }) + + it('observes a failed physical completion after reopening a required v0 prefix', async () => { + const failure = new SessionPersistenceRevisionConflictError('reopened prefix changed') + const fullCompletion = Promise.withResolvers>() + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('prefix-conflict'), + readEvents({ fromSeq = 0 } = {}) { + if (fromSeq > 0) { + return { + events: (async function* (): AsyncIterable { + yield { + type: 'steering/message', seq: 1, time: 2, + data: { turn: 1, content: [], source: { kind: 'user' } }, + } + })(), + completed: Promise.resolve({}), + } + } + return { + events: (async function* (): AsyncIterable { + fullCompletion.reject(failure) + throw failure + })(), + completed: fullCompletion.promise, + } + }, + } + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(source, id, 1) + + await expect(decodedFailure(decoded)).resolves.toBe(failure) + }) + + it('classifies every v0 prefix-independent suffix value without assuming a record', () => { + const compatibility = unversionedFormatCompatibility(0) + if (compatibility === undefined) throw new Error('v0 compatibility must be registered') + + expect(compatibility.requiresPrefix(null)).toBe(false) + expect(compatibility.requiresPrefix({ type: 'turn/end', data: null })).toBe(false) + expect(compatibility.requiresPrefix({ type: 'user/message', data: { id: 'current', content: [] } })).toBe(false) + expect(compatibility.requiresPrefix({ type: 'user/message', data: { content: [] } })).toBe(true) + expect(compatibility.requiresPrefix({ type: 'assistant/message', data: { content: [] } })).toBe(true) + expect(compatibility.requiresPrefix({ type: 'tool/result', data: { callId: 'call' } })).toBe(true) + }) + + it('preserves already-canonical v0 turn-end reasons', async () => { + const compatibility = unversionedFormatCompatibility(0) + if (compatibility === undefined) throw new Error('v0 compatibility must be registered') + const events = [ + { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'disposed' } } }, + }, + { + type: 'turn/end', seq: 1, time: 2, + data: { turn: 2, reason: { kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } } }, + }, + ] + const input = (async function* (): AsyncIterable { + yield* events + })() + const canonical: unknown[] = [] + + for await (const event of compatibility.canonicalizeEvents(input, id)) canonical.push(event) + + expect(canonical).toEqual(events) + }) + + it('does not run older registered steps for an already-current source', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + const stored = storedSource(2, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + await collectEvents(decoded.events) + await decoded.completed + + expect(calls).toEqual(['validate-header']) + expect(stored.reads).toEqual([1]) + }) + + it('opens a fresh revision-bound reader for each decode of the same source', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(0, eventLog()) + + const first = decodeStoredSession(stored.source, id) + expect(await collectEvents(first.events)).toEqual(eventLog()) + await first.completed + const second = decodeStoredSession(stored.source, id) + expect(await collectEvents(second.events)).toEqual(eventLog()) + await second.completed + + expect(first.revision).toBe(second.revision) + expect(stored.reads).toEqual([0, 0]) + }) + + it('propagates a physical revision conflict unchanged through events and completion', async () => { + const failure = new SessionPersistenceRevisionConflictError('source changed') + const physicalCompletion = Promise.withResolvers>() + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('conflicting-source'), + readEvents: () => ({ + events: (async function* (): AsyncIterable { + physicalCompletion.reject(failure) + throw failure + })(), + completed: physicalCompletion.promise, + }), + } + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(source, id) + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(failure) + expect(completionFailure).toBe(failure) + }) + + it('rejects a missing path and a future source in the correct direction', async () => { + const { decodeStoredSession, validateHeader } = await configuredDecoder(2, []) + const old = storedSource(0, []) + const future = storedSource(3, []) + + expect(() => decodeStoredSession(old.source, id)) + .toThrow(/missing v0 -> v1/) + expect(() => decodeStoredSession(future.source, id)) + .toThrow(/newer harness/) + expect(old.reads).toEqual([]) + expect(future.reads).toEqual([]) + expect(validateHeader).not.toHaveBeenCalled() + }) + + it('preserves the raw location in unsupported-format diagnostics', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(1, []) + const location = { kind: 'jsonl', path: '/tmp/session.jsonl' } + const source: StoredSessionSource = { ...stored.source, location } + + let failure: unknown + try { + decodeStoredSession(source, id) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + name: 'SessionFormatUnsupportedError', + location, + }) + expect((failure as Error).message).toContain('(raw log: /tmp/session.jsonl)') + }) + + it('rejects an invalid suffix before validating the header or opening events', async () => { + const { decodeStoredSession, validateHeader } = await configuredDecoder(0, []) + const stored = storedSource(0, eventLog()) + + for (const fromSeq of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => decodeStoredSession(stored.source, id, fromSeq)) + .toThrow(/fromSeq must be a non-negative safe integer/) + } + expect(validateHeader).not.toHaveBeenCalled() + expect(stored.reads).toEqual([]) + }) + + it('validates unknown durable header fields before path selection', async () => { + const { decodeStoredSession, validateHeader } = await configuredDecoder(0, []) + const cases: Array<{ meta: unknown; message: RegExp }> = [ + { meta: null, message: /header is not a lossless JSON record/ }, + { meta: { version: '0', id }, message: /invalid format version/ }, + { meta: { version: 0, id: 42 }, message: /has no string id/ }, + ] + let reads = 0 + + for (const entry of cases) { + const source: StoredSessionSource = { + meta: entry.meta, + revision: SessionPersistenceRevision('invalid-header'), + readEvents: () => { + reads += 1 + return { events: (async function* () {})(), completed: Promise.resolve({}) } + }, + } + expect(() => decodeStoredSession(source, id)).toThrow(entry.message) + } + expect(reads).toBe(0) + expect(validateHeader).not.toHaveBeenCalled() + }) + + it('rejects every malformed current event envelope through the stream and completion', async () => { + const { decodeStoredSession } = await configuredDecoder(1, []) + const cases: Array<{ value: unknown; message: RegExp }> = [ + { value: null, message: /non-record event/ }, + { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ }, + { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ }, + { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ }, + { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ }, + ] + + for (const entry of cases) { + const decoded = decodeStoredSession(storedSource(1, [entry.value]).source, id) + expect((await decodedFailure(decoded)).message).toMatch(entry.message) + } + }) + + it('rejects a stored event that cannot be represented as JSON', async () => { + const { decodeStoredSession } = await configuredDecoder(1, []) + const decoded = decodeStoredSession(storedSource(1, [undefined]).source, id) + + expect((await decodedFailure(decoded)).message).toMatch(/not losslessly JSON-serializable/) + }) + + it('rejects every malformed v0 event before same-version canonicalization', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const cases: Array<{ value: unknown; message: RegExp }> = [ + { value: null, message: /non-record event/ }, + { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ }, + { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ }, + { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ }, + { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ }, + ] + + for (const entry of cases) { + const decoded = decodeStoredSession(storedSource(0, [entry.value]).source, id) + expect((await decodedFailure(decoded)).message).toMatch(entry.message) + } + }) + + it('lets a v0 turn/end with opaque data reach current validation unchanged', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(storedSource(0, [ + { type: 'turn/end', seq: 0, time: 1, data: null }, + ]).source, id) + + await expect(collectEvents(decoded.events)).resolves.toEqual([ + { type: 'turn/end', seq: 0, time: 1, data: null }, + ]) + await expect(decoded.completed).resolves.toEqual({}) + }) + + it('rejects a step that returns the wrong header version', async () => { + const bad: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 0 }), + migrateEvents: events => events, + } + const first = await configuredDecoder(1, [bad]) + + expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) + .toThrow(/returned header version 0/) + expect(first.validateHeader).not.toHaveBeenCalled() + + const calls: string[] = [] + const badSecond: SessionFormatStep = { + from: 1, + to: 2, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => events, + } + const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls) + const stored = storedSource(0, []) + expect(() => second.decodeStoredSession(stored.source, id)) + .toThrow(/v1 -> v2 returned header version 1/) + expect(calls).toEqual(['header:0']) + expect(second.validateHeader).not.toHaveBeenCalled() + expect(stored.reads).toEqual([]) + }) + + it('rejects a step that changes the session id or cwd storage identity', async () => { + const changedId: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1, id: 'other' }), + migrateEvents: events => events, + } + const first = await configuredDecoder(1, [changedId]) + expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) + .toThrow(/changed session storage identity/) + + const changedCwd: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1, cwd: '/other' }), + migrateEvents: events => events, + } + const second = await configuredDecoder(1, [changedCwd]) + const stored = storedSource(0, []) + stored.meta['cwd'] = '/work' + expect(() => second.decodeStoredSession(stored.source, id)) + .toThrow(/changed session storage identity/) + }) + + it('wraps a header migration failure with the failing version step', async () => { + const cause = new Error('bad legacy header') + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: () => { throw cause }, + migrateEvents: events => events, + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + + let failure: unknown + try { + decodeStoredSession(storedSource(0, []).source, id) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + message: `session "${id}" header migration v0 -> v1 failed`, + cause, + }) + }) + + it('mirrors an event migration failure through the stream and completion promise', async () => { + const cause = new Error('bad legacy event') + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: () => (async function* (): AsyncIterable { + throw cause + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(completionFailure) + expect(streamFailure).toMatchObject({ + message: `session "${id}" event migration v0 -> v1 failed`, + cause, + }) + }) + + it('runs current event validation on the migrated output', async () => { + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => (async function* (): AsyncIterable { + for await (const value of events) { + const event = value as SessionEvent + yield { ...event, seq: event.seq + 1 } + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(completionFailure) + expect((streamFailure as Error).message).toMatch(/expected 0, got 1/) + }) + + it('runs current header validation only after the final header step', async () => { + const calls: string[] = [] + const finalStep: SessionFormatStep = { + from: 1, + to: 2, + migrateHeader(meta) { + calls.push('header:1') + const { createdAt: _createdAt, ...rest } = meta as Record + return { ...rest, version: 2 } + }, + migrateEvents: events => events, + } + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), finalStep], + calls, + ) + const stored = storedSource(0, eventLog()) + + expect(() => decodeStoredSession(stored.source, id)) + .toThrow(/current header validator received invalid createdAt/) + expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) + expect(stored.reads).toEqual([]) + }) + + it('detaches stored header and event objects before a mutating migration runs', async () => { + const originalEvents = eventLog() + const eventSnapshot = structuredClone(originalEvents) + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader(meta) { + const record = meta as Record + record['version'] = 1 + return record + }, + migrateEvents: events => (async function* (): AsyncIterable { + for await (const value of events) { + const event = value as SessionEvent + const data = event.data as Record + data['mutated'] = true + yield event + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const stored = storedSource(0, originalEvents) + + const decoded = decodeStoredSession(stored.source, id) + const migrated = await collectEvents(decoded.events) + await decoded.completed + + expect(migrated.every(event => (event.data as Record)['mutated'] === true)).toBe(true) + expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 }) + expect(originalEvents).toEqual(eventSnapshot) + }) + + it('rejects duplicate, invalid, future-targeting, and incomplete static registries at initialization', async () => { + const calls: string[] = [] + await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)])) + .rejects.toThrow(/duplicate Session format step/) + + await expect(configuredDecoder(1, [migration(-1, calls)])) + .rejects.toThrow(/adjacent non-negative version/) + + const nonAdjacent: SessionFormatStep = { + ...migration(0, calls), + to: 2, + } + await expect(configuredDecoder(2, [nonAdjacent])) + .rejects.toThrow(/adjacent non-negative version/) + + const fractional: SessionFormatStep = { + ...migration(0, calls), + from: 0.5, + to: 1.5, + } + await expect(configuredDecoder(2, [fractional])) + .rejects.toThrow(/adjacent non-negative version/) + + await expect(configuredDecoder(1, [migration(1, calls)])) + .rejects.toThrow(/targets a version newer than this build/) + + await expect(configuredDecoder(2, [migration(0, calls)])) + .rejects.toThrow(/incomplete path/) + }) +}) diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 50ad798e04..4b5d6aabaf 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -5,10 +5,13 @@ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, type StoredSuffix, + SessionPersistenceRevisionConflictError, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredEventRead, + type StoredEventReadCompletion, type StoredSessionSource, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' +import * as formatDecoder from '../src/format-decoder.ts' /** The durable store shape: materialized sessions only (no lazy entries). */ type MemoryStore = Map @@ -18,6 +21,45 @@ function memoryRevision(entry: { meta: SessionHeader; events: SessionEvent[] }): return SessionPersistenceRevision(JSON.stringify(entry)) } +/** Build one lazy physical read whose completion follows iterator exhaustion. */ +function storedRead( + load: () => Promise<{ events: readonly unknown[]; tornMarker?: TornMarker }>, +): StoredEventRead { + const completed = Promise.withResolvers>() + const events = (async function* (): AsyncIterable { + try { + const loaded = await load() + yield* loaded.events + completed.resolve(loaded.tornMarker === undefined ? {} : { tornMarker: loaded.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })() + return { events, completed: completed.promise } +} + +/** Materialize an async replacement stream for the map-backed test stores. */ +async function collectReplacement(events: AsyncIterable): Promise { + const collected: SessionEvent[] = [] + for await (const event of events) collected.push(structuredClone(event)) + return collected +} + +async function replaceMemoryStored( + store: MemoryStore, + expectedRevision: SessionPersistenceRevision, + m: SessionHeader, + events: AsyncIterable, +): Promise { + const entry = store.get(m.id) + if (entry === undefined || memoryRevision(entry) !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError(`session "${m.id}" changed before replacement`) + } + if (entry.meta.cwd !== m.cwd) throw new Error(`replacement for session "${m.id}" changes its stored identity`) + store.set(m.id, { meta: structuredClone(m), events: await collectReplacement(events) }) +} + /** An obsolete event fixture that emulates an untyped pre-change producer. */ function legacyHeaderDelta(seq = 0): SessionEvent { return { @@ -82,7 +124,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend super(ctx) // Assign the store BEFORE constructing the coordinator: the coordinator's // constructor installs the write path and synchronously seeds existing live - // sessions through loadStored(), so store must exist first. + // sessions through openStored(), so store must exist first. this.store = config?.store ?? new Map() this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -121,13 +163,20 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend // --- PersistenceBackend hooks (the Map storage primitives) --- // A Map-backed store has no torn tails, so `tornMarker` is never set. - async loadStored(id: SessionId): Promise | undefined> { + async openStored(id: SessionId): Promise | undefined> { const entry = this.store.get(id) if (!entry) return undefined + const revision = memoryRevision(entry) return { meta: structuredClone(entry.meta), - events: structuredClone(entry.events), - revision: memoryRevision(entry), + revision, + readEvents: ({ fromSeq = 0 } = {}) => storedRead(async () => { + const current = this.store.get(id) + if (current === undefined || memoryRevision(current) !== revision) { + throw new SessionPersistenceRevisionConflictError(`session "${id}" changed during read`) + } + return { events: structuredClone(current.events.filter(event => event.seq >= fromSeq)) } + }), } } @@ -161,6 +210,14 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[]) } + async replaceStored( + expectedRevision: SessionPersistenceRevision, + m: SessionHeader, + events: AsyncIterable, + ): Promise { + await replaceMemoryStored(this.store, expectedRevision, m, events) + } + async list(signal?: AbortSignal): Promise { signal?.throwIfAborted() return [...this.store.values()].map(e => structuredClone(e.meta)) @@ -185,23 +242,36 @@ class ControlledBackend implements PersistenceBackend { repairAttempts = 0 beforeAppend?: (attempt: number) => Promise beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise - /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */ - seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise + /** Optional physical suffix hook used by readFrom-specific tests. */ + seekHook?: ( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ) => Promise<{ meta: SessionHeader; events: SessionEvent[] } | undefined> - loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { - if (this.seekHook === undefined) throw new Error('seekHook not configured for this test') - return this.seekHook(id, fromSeq, signal) - } - - async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + async openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { const attempt = ++this.loadAttempts await this.beforeLoadStored?.(attempt, signal) const entry = this.store.get(id) if (entry === undefined) return undefined + const revision = memoryRevision(entry) return { meta: structuredClone(entry.meta), - events: structuredClone(entry.events), - revision: memoryRevision(entry), + revision, + readEvents: ({ fromSeq = 0 } = {}) => storedRead(async () => { + signal?.throwIfAborted() + const loaded = this.seekHook === undefined + ? { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + : await this.seekHook(id, fromSeq, signal) + if (loaded === undefined) { + throw new SessionPersistenceRevisionConflictError(`session "${id}" disappeared during read`) + } + const current = this.store.get(id) + if (current === undefined || memoryRevision(current) !== revision) { + throw new SessionPersistenceRevisionConflictError(`session "${id}" changed during read`) + } + return { events: structuredClone(loaded.events) } + }), } } @@ -228,6 +298,14 @@ class ControlledBackend implements PersistenceBackend { if (entry !== undefined) entry.events.push(...structuredClone(closers) as SessionEvent[]) } + async replaceStored( + expectedRevision: SessionPersistenceRevision, + m: SessionHeader, + events: AsyncIterable, + ): Promise { + await replaceMemoryStored(this.store, expectedRevision, m, events) + } + async list(): Promise { return [...this.store.values()].map(entry => structuredClone(entry.meta)) } @@ -537,6 +615,11 @@ describe('PersistenceCoordinator session preparations', () => { }, { inject: ['sessions'] })) try { + const immediatelyLive = Session.create(prepareId, oneTurnLog(), meta(prepareId)) + const immediateGet = vi.spyOn(ctx.sessions, 'get').mockReturnValue(immediatelyLive) + await expect(coordinator.prepare(prepareId)).rejects.toThrow(/while it is live/) + immediateGet.mockRestore() + const prepareLive = Session.create(prepareId, oneTurnLog(), meta(prepareId)) const prepareGet = vi.spyOn(ctx.sessions, 'get') .mockReturnValueOnce(undefined) @@ -1281,7 +1364,7 @@ describe('PersistenceCoordinator observation cancellation', () => { } }) - it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => { + it('readFrom via the source reader: serves the suffix, reports absence, and relays reader failures by abort state', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const backend = new ControlledBackend() @@ -1302,7 +1385,7 @@ describe('PersistenceCoordinator observation cancellation', () => { } const suffix = await coordinator.readFrom(id, 3) expect(suffix.events).toEqual(log.slice(3)) - // The hook's `undefined` is the backend contract's not-found result. + // Absence is established while opening the source, before an event read. await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found') // A hook failure with no cancellation in play propagates as-is. @@ -1310,6 +1393,20 @@ describe('PersistenceCoordinator observation cancellation', () => { backend.seekHook = () => Promise.reject(hookFailure) await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure) + // A revision conflict is retryable because it names no stable source. + let conflictAttempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + conflictAttempts += 1 + if (conflictAttempts === 1) { + throw new SessionPersistenceRevisionConflictError('source changed during readFrom') + } + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + await expect(coordinator.readFrom(id, 2)).resolves.toMatchObject({ events: log.slice(2) }) + expect(conflictAttempts).toBe(2) + // A hook failure after cancellation surfaces the caller's abort reason, // not the backend's internal teardown error. The abort fires only once // the hook is provably entered, so the failure exercises the catch (not @@ -1439,14 +1536,13 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) - // Occupy the per-id serialize chain with a gated physical read: + // Occupy the per-id serialize chain with a gated source open: // inspect() correctly borrows the still-live Session without entering // the backend chain, while both retirements must queue behind readFrom(). const readEntered = Promise.withResolvers() - backend.seekHook = async () => { + backend.beforeLoadStored = async () => { readEntered.resolve(undefined) await readGate.promise - return undefined } const parked = coordinator.readFrom(id, 0).catch((error: unknown) => error) await readEntered.promise @@ -1470,7 +1566,7 @@ describe('PersistenceCoordinator retirement', () => { // delete the successor's entry (exact-entry guard); the successor's own // forget() then clears the map. readGate.resolve(true) - expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed + expect(await parked).toBeInstanceOf(Error) // the parked read (not found) is observed await firstRetirement await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) }) } finally { @@ -1879,6 +1975,235 @@ describe('SessionPersistence service registration', () => { await Promise.allSettled([fiber.dispose()]) }) + it('rejects obsolete event variants passed directly to the persistence writer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const id = SessionId('legacy-direct-append') + await coordinator.create(meta(id)) + + try { + await expect(coordinator.append(id, [legacyHeaderDelta()])) + .rejects.toThrow(/unsupported legacy request\/header-delta event/) + await expect(coordinator.append(id, [legacyModeSet()])) + .rejects.toThrow(/unsupported legacy mode\/set event/) + await expect(coordinator.append(id, [legacyFallbackHeader()])) + .rejects.toThrow(/unsupported legacy request\/header reason "fallback"/) + expect(backend.store.has(id)).toBe(false) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retries cold preparation when its physical source revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepare-source-conflict') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let attempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + attempts += 1 + if (attempts === 1) throw new SessionPersistenceRevisionConflictError('prepare source changed') + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await expect(coordinator.inspect(id)).resolves.toMatchObject({ events: oneTurnLog() }) + expect(attempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retries live-prefix adoption when the physical source revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('hmr-source-conflict') + const m = meta(id, '/work') + backend.store.set(id, { meta: m, events: oneTurnLog() }) + const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } }) + let attempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + attempts += 1 + if (attempts === 1) throw new SessionPersistenceRevisionConflictError('live source changed') + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await expect(ctx.sessions.flush(session)).resolves.toBe(true) + expect(attempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retries ownerless seed verification when the physical source revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('seed-source-conflict') + const m = meta(id, '/work') + backend.store.set(id, { meta: m, events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await coordinator.load(id) + let attempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + attempts += 1 + if (attempts === 1) throw new SessionPersistenceRevisionConflictError('seed source changed') + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } }) + + await expect(ctx.sessions.flush(session)).resolves.toBe(true) + expect(attempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('streams an old-format prepared source into replacement and propagates non-conflict failures', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepared-format-replacement') + const m = meta(id) + backend.store.set(id, { meta: m, events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const source = { + inspection: Object.freeze({ meta: m, events: Object.freeze(oneTurnLog()) }), + session: Session.create(id, oneTurnLog(), m), + revision: memoryRevision(backend.store.get(id)!), + sourceVersion: -1, + sessionLength: oneTurnLog().length, + tornMarker: undefined, + closers: [], + } + const internals = coordinator as unknown as { + commitPrepared(value: typeof source): Promise + } + const replace = vi.spyOn(backend, 'replaceStored') + + try { + await expect(internals.commitPrepared(source)).resolves.toBeUndefined() + expect(replace).toHaveBeenCalledOnce() + expect(backend.store.get(id)?.events).toEqual(oneTurnLog()) + + const failure = new Error('replacement backend failed') + replace.mockRejectedValueOnce(failure) + source.revision = memoryRevision(backend.store.get(id)!) + await expect(internals.commitPrepared(source)).rejects.toBe(failure) + + replace.mockRejectedValueOnce(new SessionPersistenceRevisionConflictError('replacement raced')) + await expect(internals.commitPrepared(source)).resolves.toBeUndefined() + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('routes live adoption of a decoded old format through the same replacement primitive', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('live-format-replacement') + const m = meta(id, '/work') + const log = oneTurnLog() + backend.store.set(id, { meta: m, events: log }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const revision = memoryRevision(backend.store.get(id)!) + const stored: StoredSessionSource = { + meta: m, + revision, + readEvents: () => storedRead(async () => ({ events: log })), + } + const decoded = { + meta: m, + sourceVersion: -1, + revision, + events: (async function* (): AsyncIterable { yield* log })(), + completed: Promise.resolve({}), + } + const decode = vi.spyOn(formatDecoder, 'decodeStoredSession').mockReturnValue(decoded) + const replace = vi.spyOn(backend, 'replaceStored') + const internals = coordinator as unknown as { + adoptLivePrefix( + session: Session, + seed: readonly SessionEvent[], + source: StoredSessionSource, + ): Promise + } + + try { + const session = Session.create(id, log, m) + await expect(internals.adoptLivePrefix(session, log, stored)).resolves.toBe(false) + expect(replace).toHaveBeenCalledOnce() + expect(backend.store.get(id)?.events).toEqual(log) + } finally { + decode.mockRestore() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('propagates a non-conflict failure during ownerless seed verification', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('seed-source-failure') + const m = meta(id, '/work') + backend.store.set(id, { meta: m, events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await coordinator.load(id) + const failure = new Error('seed reader failed') + backend.seekHook = () => Promise.reject(failure) + const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } }) + + await expect(ctx.sessions.flush(session)).rejects.toBe(failure) + } finally { + await Promise.allSettled([fiber.dispose()]) + await ctx.fiber.dispose() + } + }) + it('rejects a stored legacy fallback header during load', async () => { const id = SessionId('legacy-fallback-load') const m = meta(id, '/legacy') From d4ff836dc23a9ddc731445a578949a7534d01be6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 18:06:48 +0800 Subject: [PATCH 02/15] refactor(session-persistence): share stored read machinery --- .../session-persistence-jsonl/src/index.ts | 43 +++------ .../session-persistence-sqlite/src/index.ts | 91 ++++++------------- .../session-persistence-sqlite/src/schema.ts | 14 +-- .../session-persistence/src/format-decoder.ts | 41 +-------- .../session-persistence/src/format-json.ts | 36 ++++++++ .../src/format-v0-compat.ts | 49 ++++------ .../session/session-persistence/src/index.ts | 33 ++++++- 7 files changed, 129 insertions(+), 178 deletions(-) create mode 100644 packages/session/session-persistence/src/format-json.ts diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index e16f343c8f..912ebd2152 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -106,20 +106,6 @@ interface JsonlStoredHeader { readonly revision: PersistenceRevision } -function deferred(): { - readonly promise: Promise - resolve(value: T): void - reject(reason: unknown): void -} { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((accept, decline) => { - resolve = accept - reject = decline - }) - return { promise, resolve, reject } -} - interface FileRevisionIdentity { readonly dev: bigint readonly ino: bigint @@ -254,28 +240,23 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi revision, location: { kind: 'jsonl', path }, readEvents: (options = {}): StoredEventRead => { - const completed = deferred<{ tornMarker?: JsonlTornMarker }>() - const events = (async function* (backend: JsonlSessionPersistence): AsyncIterable { - try { - const prefix = await backend.readPrefix(path, id, signal) + const fromSeq = options.fromSeq ?? 0 + return this.createStoredEventRead( + async () => { + const prefix = await this.readPrefix(path, id, signal) if (prefix.revision !== revision) { throw new SessionPersistenceRevisionConflictError( `session "${id}" changed while reading revision ${revision}`, ) } - const fromSeq = options.fromSeq ?? 0 - for (const event of prefix.events) { - signal?.throwIfAborted() - const seq = (event as { seq?: unknown }).seq - if (typeof seq !== 'number' || seq >= fromSeq) yield event - } - completed.resolve(prefix.tornMarker === undefined ? {} : { tornMarker: prefix.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })(this) - return { events, completed: completed.promise } + return prefix + }, + (event) => { + const seq = (event as { seq?: unknown }).seq + return typeof seq !== 'number' || seq >= fromSeq + }, + signal, + ) }, } } diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 68becaec86..c6da30c1ec 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -59,20 +59,6 @@ interface SqliteStoredPrefix { readonly tornMarker?: number } -function deferred(): { - readonly promise: Promise - resolve(value: T): void - reject(reason: unknown): void -} { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((accept, decline) => { - resolve = accept - reject = decline - }) - return { promise, resolve, reject } -} - /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. @@ -238,29 +224,22 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers meta: rowToStoredMeta(row), revision, readEvents: (options = {}): StoredEventRead => { - const completed = deferred<{ tornMarker?: number }>() - const events = (async function* (backend: SqliteSessionPersistence): AsyncIterable { - try { + return this.createStoredEventRead( + async () => { const fromSeq = options.fromSeq ?? 0 const stored = fromSeq === 0 - ? await backend.readPrefix(id, signal) - : await backend.readSuffix(id, fromSeq, signal) + ? await this.readPrefix(id, signal) + : await this.readSuffix(id, fromSeq, signal) if (stored === undefined || stored.revision !== revision) { throw new SessionPersistenceRevisionConflictError( `session "${id}" changed while reading revision ${revision}`, ) } - for (const event of stored.events) { - signal?.throwIfAborted() - yield event - } - completed.resolve(stored.tornMarker === undefined ? {} : { tornMarker: stored.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })(this) - return { events, completed: completed.promise } + return stored + }, + () => true, + signal, + ) }, } } @@ -280,33 +259,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers * region are dropped, never repaired (non-mutating read). */ private async readSuffix(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - await this.ready - signal?.throwIfAborted() - this.db.exec('BEGIN') - let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined - try { - const row = this.rowFor(id) - if (row !== undefined) { - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') - .all(id, fromSeq) as unknown as EventRow[] - snapshot = { row, eventRows } - } - this.db.exec('COMMIT') - } catch (error: unknown) { - this.db.exec('ROLLBACK') - throw error - } - signal?.throwIfAborted() - if (snapshot === undefined) return undefined - const { row, eventRows } = snapshot - const { preserved } = scanRows(eventRows, fromSeq) - return { - meta: rowToStoredMeta(row), - events: preserved, - revision: sqliteRevision(this.storeIdentity, row), - } + return this.readStoredEvents(id, fromSeq, false, signal) } /** @@ -315,6 +268,15 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers * (`scanRows` already returns it as `number | undefined`). */ private async readPrefix(id: SessionId, signal?: AbortSignal): Promise { + return this.readStoredEvents(id, 0, true, signal) + } + + private async readStoredEvents( + id: SessionId, + fromSeq: number, + includeTornMarker: boolean, + signal?: AbortSignal, + ): Promise { signal?.throwIfAborted() await this.ready signal?.throwIfAborted() @@ -323,27 +285,28 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers try { const row = this.rowFor(id) if (row !== undefined) { - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') - .all(id) as unknown as EventRow[] + const statement = fromSeq === 0 + ? this.db.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') + : this.db.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + const eventRows = (fromSeq === 0 + ? statement.all(id) + : statement.all(id, fromSeq)) as unknown as EventRow[] snapshot = { row, eventRows } } this.db.exec('COMMIT') } catch (error: unknown) { - /* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */ this.db.exec('ROLLBACK') throw error - /* v8 ignore stop */ } signal?.throwIfAborted() if (snapshot === undefined) return undefined const { row, eventRows } = snapshot - const { preserved, tornFrom } = scanRows(eventRows) + const { preserved, tornFrom } = scanRows(eventRows, fromSeq) return { meta: rowToStoredMeta(row), events: preserved, revision: sqliteRevision(this.storeIdentity, row), - ...tornFrom !== undefined ? { tornMarker: tornFrom } : {}, + ...includeTornMarker && tornFrom !== undefined ? { tornMarker: tornFrom } : {}, } } diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index e436486c09..634951e19c 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' -import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' /** * The on-disk schema version. Bumped only on a breaking change to the table @@ -180,17 +180,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) { throw new Error('stored session createdAt must be a non-negative safe integer') } - return { - version: row.version, - id: row.id as SessionId, - createdAt: row.created_at, - ...row.cwd !== null ? { cwd: row.cwd } : {}, - ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, - ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, - ...row.origin !== null ? { origin: row.origin } : {}, - ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, - ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, - } + return rowToStoredMeta(row) as SessionHeader } /** diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index 2fbc45ccd3..df11403f64 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -17,6 +17,7 @@ import { unversionedFormatCompatibility, } from './format-v0-compat.ts' import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' +import { asStoredRecord, readStoredEventEnvelope } from './format-json.ts' import type { SessionLocation } from './index.ts' import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' import type { SessionPersistenceRevision } from './revision.ts' @@ -163,12 +164,6 @@ interface DecodedHeader { readonly unversionedCompatibility?: UnversionedFormatCompatibility } -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined -} - interface StoredHeaderSource { readonly meta: unknown readonly location?: SessionLocation @@ -190,7 +185,7 @@ function readSourceHeader( expectedId: SessionId, ): { meta: Record; version: number; id: SessionId } { const snapshot = snapshotJsonValue(source.meta) - const meta = asRecord(snapshot) + const meta = asStoredRecord(snapshot) if (meta === undefined) throw new Error('stored session header is not a lossless JSON record') if (!Number.isSafeInteger(meta['version'])) { throw new Error(`stored session header has invalid format version ${String(meta['version'])}`) @@ -243,7 +238,7 @@ function decodeHeader( { cause: error }, ) } - const record = asRecord(meta) + const record = asStoredRecord(meta) const actual = record?.['version'] if (actual !== step.to) { throw new Error(`Session format step v${step.from} -> v${step.to} returned header version ${String(actual)}`) @@ -282,19 +277,7 @@ export function decodeStoredSessionHeader( function assertCurrentEnvelope(value: unknown, id: SessionId): SessionEvent { const snapshot = snapshotJsonValue(value) - const event = asRecord(snapshot) - if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) - if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) - if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { - throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) - } - if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) - } - if (!Object.hasOwn(event, 'data')) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) - } - return event as unknown as SessionEvent + return readStoredEventEnvelope(snapshot, id) } function assertCurrentEventSupported( @@ -376,20 +359,6 @@ async function* snapshotStoredEvents( } } -function deferred(): { - readonly promise: Promise - resolve(value: T): void - reject(reason: unknown): void -} { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((accept, decline) => { - resolve = accept - reject = decline - }) - return { promise, resolve, reject } -} - function decodedRead( source: StoredSessionSource, header: DecodedHeader, @@ -398,7 +367,7 @@ function decodedRead( readonly events: AsyncIterable readonly completed: Promise> } { - const completion = deferred>() + const completion = Promise.withResolvers>() const migrating = header.steps.length > 0 const compatibility = header.unversionedCompatibility let physical: StoredEventRead | undefined diff --git a/packages/session/session-persistence/src/format-json.ts b/packages/session/session-persistence/src/format-json.ts new file mode 100644 index 0000000000..f957a7e153 --- /dev/null +++ b/packages/session/session-persistence/src/format-json.ts @@ -0,0 +1,36 @@ +/** Shared JSON validation for stored Session format records. */ + +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' + +/** + * Narrow an unknown JSON value to a non-array object. + * @param value - parsed JSON value. + * @returns the object, or `undefined` for every other JSON value. + */ +export function asStoredRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +/** + * Validate fields common to every stored Session event envelope. + * @param value - detached parsed event JSON. + * @param id - Session identity used in diagnostics. + * @returns the structurally valid event envelope. + */ +export function readStoredEventEnvelope(value: unknown, id: SessionId): SessionEvent { + const event = asStoredRecord(value) + if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) + if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) + if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { + throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) + } + if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) + } + if (!Object.hasOwn(event, 'data')) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) + } + return event as unknown as SessionEvent +} diff --git a/packages/session/session-persistence/src/format-v0-compat.ts b/packages/session/session-persistence/src/format-v0-compat.ts index 760e8db8ce..f556936834 100644 --- a/packages/session/session-persistence/src/format-v0-compat.ts +++ b/packages/session/session-persistence/src/format-v0-compat.ts @@ -4,6 +4,7 @@ */ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { asStoredRecord, readStoredEventEnvelope } from './format-json.ts' /** One format-specific normalizer selected before adjacent-version migrations. */ export interface UnversionedFormatCompatibility { @@ -25,12 +26,6 @@ export interface UnversionedFormatCompatibility { canonicalizeEvents(events: AsyncIterable, sessionId: SessionId): AsyncIterable } -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined -} - function hasOnlyKeys( record: Record, required: readonly string[], @@ -48,16 +43,16 @@ function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { } function replacementStart(event: SessionEvent): number | undefined { - const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) + const op = asStoredRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) return op?.['op'] === 'replace' && typeof op['start'] === 'number' ? op['start'] : undefined } function requiresV0Prefix(value: unknown): boolean { - const event = asRecord(value) + const event = asStoredRecord(value) if (event === undefined) return false - const data = asRecord(event['data']) + const data = asStoredRecord(event['data']) if (event['type'] === 'steering/message') return true if (data === undefined) return false switch (event['type']) { @@ -73,29 +68,17 @@ function requiresV0Prefix(value: unknown): boolean { } function readV0Event(value: unknown, id: SessionId): SessionEvent { - const event = asRecord(value) - if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) - if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) - if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { - throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) - } - if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) - } - if (!Object.hasOwn(event, 'data')) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) - } - return event as unknown as SessionEvent + return readStoredEventEnvelope(value, id) } function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { const legacyType: string = 'steering/message' if (event.type !== legacyType) return event - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined) { throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) } - const wrapped = asRecord(data['message']) + const wrapped = asStoredRecord(data['message']) if (wrapped !== undefined && Number.isSafeInteger(data['turn']) && hasOnlyKeys(data, ['turn', 'message'])) { return { ...event, type: 'user/message', data: wrapped } as SessionEvent @@ -113,9 +96,9 @@ function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): Se function canonicalizeLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { if (event.type !== 'turn/start') return event - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined || !Object.hasOwn(data, 'trigger')) return event - const trigger = asRecord(data['trigger']) + const trigger = asStoredRecord(data['trigger']) if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 || !hasOnlyKeys(data, ['turn', 'trigger']) || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { @@ -126,12 +109,12 @@ function canonicalizeLegacyTurnStartEvent(event: SessionEvent, id: SessionId): S function canonicalizeLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { if (event.type !== 'turn/end') return event - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined) return event const malformed = (): never => { throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) } - const reason = asRecord(data['reason']) + const reason = asStoredRecord(data['reason']) if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 || !hasOnlyKeys(data, ['turn', 'reason']) || reason === undefined || typeof reason['kind'] !== 'string') return malformed() @@ -156,7 +139,7 @@ function canonicalizeLegacyTurnEndEvent(event: SessionEvent, id: SessionId): Ses case 'error': { if (Object.hasOwn(reason, 'error')) return event if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() - const failure = asRecord(reason['failure']) + const failure = asStoredRecord(reason['failure']) if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' @@ -192,7 +175,7 @@ function canonicalizeLegacyMessageEvent( id: SessionId, messageIds: ReadonlyMap, ): SessionEvent { - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined) return event switch (event.type) { case 'user/message': @@ -212,7 +195,7 @@ function canonicalizeLegacyMessageEvent( id: legacyMessageId(id, event.seq), role: 'assistant', content, - source: { ...asRecord(provenance), kind: 'model' }, + source: { ...asStoredRecord(provenance), kind: 'model' }, }, }, } as SessionEvent @@ -242,8 +225,8 @@ function canonicalizeLegacyMessageEvent( } function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { - const data = asRecord(event.data) - const message = event.type === 'user/message' ? data : asRecord(data?.['message']) + const data = asStoredRecord(event.data) + const message = event.type === 'user/message' ? data : asStoredRecord(data?.['message']) return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined } diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index fafb17e875..297a768b75 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -6,9 +6,9 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import { SessionPreparation } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionPreparation, type SessionEvent, type SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' +import type { StoredEventRead, StoredEventReadCompletion } from './format-decoder.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' @@ -86,6 +86,35 @@ export abstract class SessionPersistence extends Service { super(ctx, 'sessionPersistence') } + /** + * Build the standard lazy event stream and EOF metadata around one backend read. + * @param load - revision-checked batch loader owned by the backend. + * @param include - whether one loaded event belongs in this physical read. + * @param signal - optional cancellation checked between yielded events. + * @returns an independently consumable event read. + */ + protected createStoredEventRead( + load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>, + include: (event: unknown) => boolean, + signal?: AbortSignal, + ): StoredEventRead { + const completed = Promise.withResolvers>() + const events = (async function* (): AsyncIterable { + try { + const batch = await load() + for (const event of batch.events) { + signal?.throwIfAborted() + if (include(event)) yield event + } + completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })() + return { events, completed: completed.promise } + } + /** * Resolve this backend's independent local artifact for a session without * reading, creating, flushing, or otherwise materializing it. Backends such From f73f2d9b65f07c8c6dc0c6d55211ac3d69ed4075 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 18:15:17 +0800 Subject: [PATCH 03/15] docs(config): refresh persistence source link --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 79b4f3b631..ec368e2aa6 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4d7e2f559328e8894c05ad0b9c791871b3b1434a -config-catalog.zh.md: 36988f2061326632b798d64d60eaf33ba7a5a765 +config-catalog.md: eec463096685b311c34e21c6284bcba0e34beadc +config-catalog.zh.md: d8d2c756797e8cce10fc6c829269d0a0e97bb4a5 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4d7e2f5593..eec4630966 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1635,7 +1635,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:79`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 36988f2061..d8d2c75679 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1637,7 +1637,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:79`](../packages/session/session-persistence-sqlite/src/index.ts) From c4b3f48e64204dd7a027bc0d3f010975aa9f0b88 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 11:07:23 +0800 Subject: [PATCH 04/15] fix(session): address format migration review --- .../README.i18n.yaml | 4 ++-- .../session-persistence-jsonl/README.md | 4 ++-- .../session-persistence-jsonl/README.zh.md | 4 ++-- .../session-persistence/README.i18n.yaml | 4 ++-- .../session/session-persistence/README.md | 20 ++++++++++------ .../session/session-persistence/README.zh.md | 20 ++++++++++------ .../session-persistence/src/coordinator.ts | 17 ++----------- .../session-persistence/src/format-decoder.ts | 24 +++++++------------ .../session-persistence/src/format-json.ts | 20 ++++++++++++++++ .../tests/format-decoder.spec.ts | 17 +++++++++++++ 10 files changed, 81 insertions(+), 53 deletions(-) diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 2184f491c1..8048b9d849 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 4cff3215cdb083d2fdb7c4a8f1b60e8c4028ba84 -README.zh.md: 38b41cb9f7fa0f144923feced06152a185254c0a +README.md: 00f893c134133207a1e9a12397c996c7c6c0c76c +README.zh.md: fc9ee432d162edeaf6472b31ca98004da11b7c16 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 4cff3215cd..00f893c134 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -35,7 +35,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation. -A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. There is no migration, mixed-root fallback, or dual write. +A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. Session format steps can replace a logical log within its configured encoding; there is no compression migration, mixed-root fallback, or dual write. ## Durability and crash semantics @@ -69,7 +69,7 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr ## Known Limitations and Deferred Work -- **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. +- **Only format versions with a complete registered upgrade path load** — the registry is empty while `SESSION_FORMAT_VERSION` remains v0. Changing compression still requires a separate/fresh root or selecting the legacy raw mode. - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion API). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 38b41cb9f7..fc9ee432d1 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -35,7 +35,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d 默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。 -一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。不提供迁移、混合根回退或双写。 +一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。Session 格式步骤可以在已配置编码内替换逻辑日志;不提供压缩迁移、混合根回退或双写。 ## 持久性与崩溃语义 @@ -69,7 +69,7 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope ## 已知限制与暂缓事项 -- **只加载已配置编码和当前 `SESSION_FORMAT_VERSION`(v0)**:更改压缩需要独立/全新根,或选择遗留原始 mode;预发布格式没有迁移。 +- **只加载存在完整注册升级路径的格式版本**:`SESSION_FORMAT_VERSION` 保持 v0 时 registry 为空。更改压缩仍需要独立/全新根,或选择遗留原始 mode。 - **平铺文件存储布局不加载**:加载前使用独立根,或将预发布产物移入项目/会话目录布局。 - **压缩文件不能直接按行读取**:使用后端加载;或在写入新根前选择 `compression: 'none'`,以便外部行 reader 使用。 - **不删除会话文件**:日志在 `root` 下累积,直到外部移除(seam 无删除接口)。 diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 74b808b05e..dd8d1daf18 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 335f40e7439fc4a49fa49bb4541f101250468d1b -README.zh.md: 5a50f051a2c53a5f27b009df11ad46f5952a32ac +README.md: 1ba00fc9e1139e7bf63f22a4014632cb705c0e68 +README.zh.md: b5e5896da80b9b53559319b63a9bbd858a216ea0 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 335f40e743..1ba00fc9e1 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -16,9 +16,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after decoding a supported format path and committing any format replacement plus cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Current-format reads request a suffix from the backend; a format step requires the complete source and applies `fromSeq` only after migration. Sequential media may still scan framing before filtering, while seek-capable media can avoid reading earlier rows. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -35,9 +35,15 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure. -Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. +Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption opens the same revision-bound source, applies the coordinator's cwd check, and never closes the active turn. -Backend reads convert the exact supported older records from the same format version before validating current records. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same converted view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current format. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. +## Format decoding and upgrades + +Every logical read opens a repeatable `StoredSessionSource` containing an untrusted header, an exact revision, and a `readEvents()` factory. The static decoder chooses a complete adjacent-version path, streams each `migrateEvents()` transform, and validates the final header and events as the current format. `inspect()` and `readFrom()` do not write. Cold continuation and live adoption replace a converted source through the backend's revision compare-and-swap, then reopen it; a concurrent change discards the decoded result and restarts from the new source. The [session-log versioning Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns the rationale and refusal rules. + +A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports the step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step validates every accepted vN header/event variant, preserves id and cwd, returns header version N+1, and keeps any cross-event state inside its iterator. Backends and the coordinator remain version-independent. + +The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions. These compatibility transforms are not version steps. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. @@ -48,11 +54,11 @@ The `PersistenceBackend` hooks (the only contract between the coordi | Hook | Role | |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; `revision` identifies exactly the returned header and events; an opaque `tornMarker` is present iff a torn tail must be truncated. | -| `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `loadStored` and returns `undefined` when the id is absent. | -| `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. | +| `openStored(id, signal?)` | Open an untrusted header plus repeatable event readers bound to one exact source revision. Each `readEvents({ fromSeq? })` reproduces that revision and exposes backend-owned torn-tail metadata only after EOF, or rejects with `SessionPersistenceRevisionConflictError` when the source changed. | +| `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `openStored` and returns `undefined` when the id is absent. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | +| `replaceStored(expectedRevision, meta, events)` | Atomically replace one exact revision with a complete current-format header and event stream. Revision and stored identity checks occur inside the same backend exclusion or transaction as the commit; a mismatch rejects with `SessionPersistenceRevisionConflictError`. | | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 5a50f051a2..b5e5896da8 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -16,9 +16,9 @@ | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose(资源释放)时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | +| `load(id): Promise<{ meta; events }>` | 沿受支持的格式路径解码,并提交格式替换与冷恢复后,返回不可变、平衡的逻辑日志。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。当前格式读取向后端请求 suffix;存在格式步骤时则读取完整 source,迁移后才应用 `fromSeq`。顺序介质可能仍需扫描物理 framing 后再过滤,可寻址介质则可不读取更早的记录。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -35,9 +35,15 @@ 每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。 -崩溃修复只适用于冷状态。对于已有活动会话的 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;活动会话中开放的轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR(热模块替换)接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 +崩溃修复只适用于冷状态。对于已有活动会话的 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;活动会话中开放的轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR(热模块替换)接管打开同一份绑定 revision 的 source,应用协调器 cwd 检查,并绝不关闭活动轮次。 -后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +## 格式解码与升级 + +每次逻辑读取都会打开可重复使用的 `StoredSessionSource`,其中包含不可信 header、精确 revision 和 `readEvents()` factory。静态 decoder 选择完整的相邻版本路径,以流式方式执行各个 `migrateEvents()` 转换,最后按当前格式验证 header 与事件。`inspect()` 和 `readFrom()` 不写存储;冷 continuation 与实时接管通过后端的 revision compare-and-swap 替换已转换 source,然后重新打开。并发变更会丢弃解码结果,并从新 source 重新开始。[Session log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)规定其原因和拒绝规则。 + +以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加步骤,从静态 `SESSION_FORMAT_STEPS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。该步骤验证它接受的所有 vN header/event 变体,保持 id 与 cwd 不变,返回 version 为 N+1 的 header,并把跨事件状态保留在自己的 iterator 中。后端和协调器不增加版本特判。 + +v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体。这些兼容转换不是版本步骤。 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 @@ -48,11 +54,11 @@ | 钩子 | 职责 | |---|---| | `name` | dispose 失败 `AggregateError` 的后端标签。 | -| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于恢复/加载、非修改式 inspect、活动会话接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;`revision` 精确标识返回的 header 和事件;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | -| `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `loadStored` 相同的修订值表示;id 不存在时返回 `undefined`。 | -| `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非修改式、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 | +| `openStored(id, signal?)` | 打开不可信 header 和绑定同一精确 source revision 的可重复事件 reader。每次 `readEvents({ fromSeq? })` 都重现该 revision,并只在 EOF 后暴露 backend 自有 torn-tail metadata;source 已变化时以 `SessionPersistenceRevisionConflictError` 拒绝。 | +| `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `openStored` 相同的修订值表示;id 不存在时返回 `undefined`。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和活动会话接管(仅截断)使用。 | +| `replaceStored(expectedRevision, meta, events)` | 用完整的当前格式 header 与事件流原子替换一个精确 revision。Revision 与存储身份检查和提交位于同一个 backend 排他区或事务内;不匹配时以 `SessionPersistenceRevisionConflictError` 拒绝。 | | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 0fa2bf1df9..c28f239d84 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -20,6 +20,7 @@ import { decodeStoredSession, SessionFormatUnsupportedError, } from './format-decoder.ts' +import { assertNoRetiredSessionEvent } from './format-json.ts' import type { DecodedSession, StoredSessionSource, @@ -194,21 +195,7 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio /** Reject obsolete v0 event records before a live writer persists them. */ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void { - const legacyType: string = 'request/header-delta' - const legacy = events.find(event => event.type === legacyType) - if (legacy !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`) - } - const legacyModeType: string = 'mode/set' - const legacyMode = events.find(event => event.type === legacyModeType) - if (legacyMode !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`) - } - const fallback = events.find(event => event.type === 'request/header' - && (event.data as { reason?: string }).reason === 'fallback') - if (fallback !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`) - } + for (const event of events) assertNoRetiredSessionEvent(event, id) } /** Materialize one decoded event read and observe its physical EOF metadata. */ diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index df11403f64..be10bd17ad 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -17,10 +17,10 @@ import { unversionedFormatCompatibility, } from './format-v0-compat.ts' import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' -import { asStoredRecord, readStoredEventEnvelope } from './format-json.ts' +import { asStoredRecord, assertNoRetiredSessionEvent, readStoredEventEnvelope } from './format-json.ts' import type { SessionLocation } from './index.ts' import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' -import type { SessionPersistenceRevision } from './revision.ts' +import { SessionPersistenceRevisionConflictError, type SessionPersistenceRevision } from './revision.ts' /** Stable facts available to one format step invocation. */ export interface SessionFormatContext { @@ -35,14 +35,16 @@ export interface SessionFormatStep { /** Output Session format version; must equal `from + 1`. */ readonly to: number /** - * Transform and validate the header fields understood by this step. + * Transform and validate the header fields understood by this step. The + * detached result must carry {@link to} and preserve the source id and cwd. * @param meta - detached input header for {@link from}. * @param context - stable session identity. * @returns detached header JSON carrying {@link to}. */ migrateHeader(meta: unknown, context: SessionFormatContext): unknown /** - * Lazily transform and validate events understood by this step. + * Lazily transform and validate events understood by this step. The output + * must be detached, losslessly JSON-serializable, and contiguous by seq. * @param events - detached input records in durable sequence order. * @param context - stable session identity. * @returns a lazy output stream in the next format. @@ -285,18 +287,7 @@ function assertCurrentEventSupported( meta: SessionHeader, event: SessionEvent, ): void { - const legacyType: string = 'request/header-delta' - if (event.type === legacyType) { - throw new Error(`session "${meta.id}" contains unsupported legacy request/header-delta event at seq ${event.seq}`) - } - const legacyModeType: string = 'mode/set' - if (event.type === legacyModeType) { - throw new Error(`session "${meta.id}" contains unsupported legacy mode/set event at seq ${event.seq}`) - } - if (event.type === 'request/header' - && (event.data as { reason?: string }).reason === 'fallback') { - throw new Error(`session "${meta.id}" contains unsupported legacy request/header reason "fallback" at seq ${event.seq}`) - } + assertNoRetiredSessionEvent(event, meta.id) if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) return throw unsupported( source, @@ -336,6 +327,7 @@ function transformEvents( try { yield* step.migrateEvents(input, context) } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) throw error throw new Error( `session "${id}" event migration v${step.from} -> v${step.to} failed`, { cause: error }, diff --git a/packages/session/session-persistence/src/format-json.ts b/packages/session/session-persistence/src/format-json.ts index f957a7e153..f4e911a220 100644 --- a/packages/session/session-persistence/src/format-json.ts +++ b/packages/session/session-persistence/src/format-json.ts @@ -34,3 +34,23 @@ export function readStoredEventEnvelope(value: unknown, id: SessionId): SessionE } return event as unknown as SessionEvent } + +/** + * Reject event records retired before the current durable event vocabulary. + * @param event - current-envelope event presented for reading or writing. + * @param id - Session identity used in diagnostics. + */ +export function assertNoRetiredSessionEvent(event: SessionEvent, id: SessionId): void { + const retiredType: string = 'request/header-delta' + if (event.type === retiredType) { + throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${event.seq}`) + } + const retiredModeType: string = 'mode/set' + if (event.type === retiredModeType) { + throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${event.seq}`) + } + if (event.type === 'request/header' + && (event.data as { reason?: string }).reason === 'fallback') { + throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${event.seq}`) + } +} diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index e503095bf6..6435eb7821 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -560,6 +560,23 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(completionFailure).toBe(failure) }) + it('propagates an upstream revision conflict unchanged through a migration step', async () => { + const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])]) + const { SessionPersistenceRevisionConflictError: DecoderRevisionConflictError } = await import('../src/revision.ts') + const failure = new DecoderRevisionConflictError('migrating source changed') + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('conflicting-migration-source'), + readEvents: () => ({ + events: (async function* (): AsyncIterable { + throw failure + })(), + completed: Promise.reject(failure), + }), + } + await expect(decodedFailure(decodeStoredSession(source, id))).resolves.toBe(failure) + }) + it('rejects a missing path and a future source in the correct direction', async () => { const { decodeStoredSession, validateHeader } = await configuredDecoder(2, []) const old = storedSource(0, []) From 44feadea05f5a571b928923a418e068bc46ba0c0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 11:15:25 +0800 Subject: [PATCH 05/15] fix(session): load legacy compact events --- ...10-session-log-version-mechanism.i18n.yaml | 4 +- ...026-08-10-session-log-version-mechanism.md | 2 +- ...-08-10-session-log-version-mechanism.zh.md | 2 +- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../src/format-v0-compat.ts | 23 +++++++- .../tests/format-decoder.spec.ts | 53 +++++++++++++++++++ 8 files changed, 83 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index 2d9bfcd187..f5b6075335 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: eb9fcef3fa677ee3912c2cbd611601b6b6f83db1 -2026-08-10-session-log-version-mechanism.zh.md: aa257482869a2b18bd0d3fcb2bc883a8cf506dcf +2026-08-10-session-log-version-mechanism.md: 058f3a98b67e35d6c6e46f7bb8bc625d419af7f1 +2026-08-10-session-log-version-mechanism.zh.md: 8738f3bbabd34804401859d847ca69b69a6b5d2b diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index eb9fcef3fa..058f3a98b6 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -18,7 +18,7 @@ Session logs must be upgradable after release, and the runtime that ships first **Format migration is the decoder, not a Coordinator repair branch.** Backends expose parsed durable data as `unknown` through a repeatable `StoredSessionSource`: one raw header, one exact revision, and `readEvents()` factories that create independently consumable `AsyncIterable` streams bound to that revision. Each step implements `migrateHeader()` and lazy `migrateEvents()` transforms, must advance exactly one version, and may not change the session id or cwd. Any version conversion reads the complete event stream and applies the requested suffix only after all steps; an equal-version read retains backend suffix seek. The decoder validates each output header version, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain. -**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version steps. +**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version steps. This decoder maps the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to canonical `compaction/*` events while preserving the rest of each record. **Recovery and writeback consume current-format data.** `inspect()` and `readFrom()` decode only in memory. Cold `prepare()`/`load()` first decode the whole source, add the current recovery closers, and replace the exact old revision with that complete balanced current-format stream. Live HMR adoption uses the same replacement primitive after seed verification but does not synthesize closers for a turn still owned by the live Session. A successful replacement or revision conflict discards the prepared object and reopens the stored source before continuing. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index aa25748286..8738f3bbab 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -18,7 +18,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每一步实现 `migrateHeader()` 和惰性的 `migrateEvents()` 转换,只能前进一个版本,也不能改变 Session id 或 cwd。只要发生版本转换,就读取完整事件流,并在所有步骤完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。 -**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。 +**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。该 decoder 将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称映射为规范的 `compaction/*` 事件,并保留每条记录的其余内容。 **Recovery 和写回只消费当前格式数据。**`inspect()` 和 `readFrom()` 只在内存中解码。Cold `prepare()`/`load()` 先解码完整 source,补充当前 recovery closers,再用完整、平衡的当前格式 stream 替换精确的旧 revision。Live HMR adoption 在 seed 校验后使用同一个 replacement primitive,但不会为仍由 live Session 掌握的 turn 合成 closer。替换成功或 revision 冲突后都会丢弃 prepared object,重新打开持久化 source 后再继续。 diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index dd8d1daf18..ac91bae831 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 1ba00fc9e1139e7bf63f22a4014632cb705c0e68 -README.zh.md: b5e5896da80b9b53559319b63a9bbd858a216ea0 +README.md: 5c14654dba4bc02a79113cb03fda881b30b1e0fc +README.zh.md: 843dff51da44aebd1c9e475a91ab045f4815ca89 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 1ba00fc9e1..5c14654dba 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -43,7 +43,7 @@ Every logical read opens a repeatable `StoredSessionSource` containing an untrus A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports the step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step validates every accepted vN header/event variant, preserves id and cwd, returns header version N+1, and keeps any cross-event state inside its iterator. Backends and the coordinator remain version-independent. -The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions. These compatibility transforms are not version steps. +The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, and normalizes the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to their canonical `compaction/*` names. These compatibility transforms are not version steps. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index b5e5896da8..843dff51da 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -43,7 +43,7 @@ 以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加步骤,从静态 `SESSION_FORMAT_STEPS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。该步骤验证它接受的所有 vN header/event 变体,保持 id 与 cwd 不变,返回 version 为 N+1 的 header,并把跨事件状态保留在自己的 iterator 中。后端和协调器不增加版本特判。 -v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体。这些兼容转换不是版本步骤。 +v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体,并将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称归一化为规范的 `compaction/*` 名称。这些兼容转换不是版本步骤。 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 diff --git a/packages/session/session-persistence/src/format-v0-compat.ts b/packages/session/session-persistence/src/format-v0-compat.ts index f556936834..9ad747818e 100644 --- a/packages/session/session-persistence/src/format-v0-compat.ts +++ b/packages/session/session-persistence/src/format-v0-compat.ts @@ -71,6 +71,26 @@ function readV0Event(value: unknown, id: SessionId): SessionEvent { return readStoredEventEnvelope(value, id) } +/** + * PR #2302 changed these durable v0 discriminants without a format-version bump. + * @see https://github.com/deepseek-harness/deepseek-harness/pull/2302 + */ +function canonicalizeLegacyCompactionEvent(event: SessionEvent): SessionEvent { + const type: string = event.type + switch (type) { + case 'compact/start': + return { ...event, type: 'compaction/start' } as SessionEvent + case 'compact/summary': + return { ...event, type: 'compaction/summary' } as SessionEvent + case 'compact/end': + return { ...event, type: 'compaction/end' } as SessionEvent + case 'compact/prune': + return { ...event, type: 'compaction/prune' } as SessionEvent + default: + return event + } +} + function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { const legacyType: string = 'steering/message' if (event.type !== legacyType) return event @@ -237,7 +257,8 @@ async function* canonicalizeV0Events( const messageIds = new Map() for await (const value of events) { const event = readV0Event(value, id) - const turnStart = canonicalizeLegacyTurnStartEvent(event, id) + const compaction = canonicalizeLegacyCompactionEvent(event) + const turnStart = canonicalizeLegacyTurnStartEvent(compaction, id) const turnEnd = canonicalizeLegacyTurnEndEvent(turnStart, id) const steering = canonicalizeLegacySteeringEvent(turnEnd, id) const canonical = canonicalizeLegacyMessageEvent(steering, id, messageIds) diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 6435eb7821..5d4c0c9d0e 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -504,6 +504,59 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(canonical).toEqual(events) }) + it('canonicalizes every historical compact event name without changing its record', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const events = [ + { + type: 'compact/start', seq: 0, time: 1, + data: { compactionId: 'legacy', turn: 1 }, + surfaceOp: { op: 'retain' }, + }, + { + type: 'compact/summary', seq: 1, time: 2, + data: { summary: 'old summary', shadowedSeqs: [7, 8] }, + durableMetadata: { source: 'historical-v0' }, + }, + { + type: 'compaction/end', seq: 2, time: 3, + data: { compactionId: 'current', turn: 1 }, + }, + { + type: 'compact/end', seq: 3, time: 4, + data: { compactionId: 'legacy', turn: 1 }, + }, + { + type: 'compact/prune', seq: 4, time: 5, + data: { + shadowedRange: { start: 7, end: 8 }, + shadowedSeqs: [7, 8], + shadowedTokenCount: 456, + }, + }, + ] + const stored = storedSource(0, events) + + const decoded = decodeStoredSession(stored.source, id) + const canonical = await collectEvents(decoded.events) + await decoded.completed + + expect(canonical).toEqual(events.map(event => ({ + ...event, + type: event.type.replace(/^compact\//, 'compaction/'), + }))) + expect(stored.reads).toEqual([0]) + }) + + it('still rejects other unknown v0 event names after compaction normalization', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(storedSource(0, [{ + type: 'compact/future', seq: 0, time: 1, data: {}, + }]).source, id) + + const failure = await decodedFailure(decoded) + expect(failure.message).toMatch(/event type "compact\/future".*not marked ignorable/) + }) + it('does not run older registered steps for an already-current source', async () => { const calls: string[] = [] const { decodeStoredSession } = await configuredDecoder( From e8f4315ceed1ade5112b7b1dfa0d054e09c26cd6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 20:31:31 +0800 Subject: [PATCH 06/15] fix(session): scope format registry completeness to per-session decode buildStepIndex rejected the whole decoder at initialization whenever any registered step could not reach the current version, so one retired old upgrader blocked every session, including later versions whose path to the current version is complete. planSteps already refuses a specific stored version when a needed step is missing; initialization now checks only step legality and duplicates. --- .../session-persistence/src/format-decoder.ts | 13 +++------- .../tests/format-decoder.spec.ts | 26 ++++++++++++++++--- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index be10bd17ad..3a136441d0 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -145,15 +145,10 @@ function buildStepIndex(steps: readonly SessionFormatStep[]): ReadonlyMap v${version + 1}`, - ) - } - } - } + // A missing step is a per-session concern, decided by planSteps() at decode + // time: it refuses sessions at or below the gap, while later versions whose + // path to the current version is complete still upgrade. Initialization + // therefore checks only step legality and duplicates here. return byFrom } diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 5d4c0c9d0e..30004d4f76 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -927,7 +927,7 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(originalEvents).toEqual(eventSnapshot) }) - it('rejects duplicate, invalid, future-targeting, and incomplete static registries at initialization', async () => { + it('rejects duplicate, invalid, and future-targeting static registries at initialization', async () => { const calls: string[] = [] await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)])) .rejects.toThrow(/duplicate Session format step/) @@ -952,8 +952,28 @@ describe('versioned Session format decoder', { concurrent: false }, () => { await expect(configuredDecoder(1, [migration(1, calls)])) .rejects.toThrow(/targets a version newer than this build/) + }) - await expect(configuredDecoder(2, [migration(0, calls)])) - .rejects.toThrow(/incomplete path/) + it('initializes with a gapped registry and refuses only sessions at or below the gap', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 3, + [migration(0, calls), migration(2, calls)], + calls, + ) + const current = storedSource(3, []) + const pastGap = storedSource(2, eventLog()) + const atGap = storedSource(1, eventLog()) + const belowGap = storedSource(0, eventLog()) + + expect(decodeStoredSession(current.source, id).meta.version).toBe(3) + const decoded = decodeStoredSession(pastGap.source, id) + expect(decoded.sourceVersion).toBe(2) + expect(decoded.meta.version).toBe(3) + expect(() => decodeStoredSession(atGap.source, id)) + .toThrow(/missing v1 -> v2/) + expect(() => decodeStoredSession(belowGap.source, id)) + .toThrow(/missing v1 -> v2/) + expect(pastGap.reads).toEqual([]) }) }) From 270a06b38bf7a6e0528659893249104eb184a10e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 20:42:56 +0800 Subject: [PATCH 07/15] fix(session-persistence-jsonl): drop crash-unsafe cross-process log lock The product model has no cross-process writer exclusion (the coordinator serializes per-session operations in-process; the README documents one live writer per session), so the wx-created .lock sibling only guarded byte-level races while adding two failure modes: a crash leaves a stale lock that permanently wedges that log's appends/repairs/replacements, and a post-commit lock cleanup failure makes a committed append look failed, so the retained write-behind batch retries into duplicate seqs. Remove withLogLock and keep replaceStored's revision compare-and-swap at the commit boundary (recheck immediately before the atomic rename). --- ...026-08-10-session-log-version-mechanism.md | 2 +- ...-08-10-session-log-version-mechanism.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 116 ++++++------------ .../tests/jsonl.spec.ts | 26 ---- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session-persistence/src/coordinator.ts | 5 +- 7 files changed, 46 insertions(+), 109 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 058f3a98b6..8515d04ebf 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -22,7 +22,7 @@ Session logs must be upgradable after release, and the runtime that ships first **Recovery and writeback consume current-format data.** `inspect()` and `readFrom()` decode only in memory. Cold `prepare()`/`load()` first decode the whole source, add the current recovery closers, and replace the exact old revision with that complete balanced current-format stream. Live HMR adoption uses the same replacement primitive after seed verification but does not synthesize closers for a turn still owned by the live Session. A successful replacement or revision conflict discards the prepared object and reopens the stored source before continuing. -**Replacement is an internal backend compare-and-swap.** `replaceStored(expectedRevision, meta, events)` accepts a streaming current-format log and checks storage identity plus the source revision inside the commit exclusion. JSONL writes and fsyncs a sibling temporary artifact, rechecks the revision while holding its cross-process lock, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX. SQLite stages the event iterator, then rechecks and replaces the header and event rows in one transaction. A failed commit leaves one complete old or new log; retaining a permanent pre-upgrade copy is a separate recovery policy, not part of the format migration API. +**Replacement is an internal backend compare-and-swap.** `replaceStored(expectedRevision, meta, events)` accepts a streaming current-format log and checks storage identity plus the source revision at the commit boundary. JSONL writes and fsyncs a sibling temporary artifact, rechecks the source revision immediately before the atomic replace, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX; like every other coordinator freshness check, the recheck adds no cross-process writer exclusion — JSONL assumes one live writer per session. SQLite stages the event iterator, then rechecks and replaces the header and event rows in one transaction. A failed commit leaves one complete old or new log; retaining a permanent pre-upgrade copy is a separate recovery policy, not part of the format migration API. **A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index 8738f3bbab..04e66b5da3 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -22,7 +22,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **Recovery 和写回只消费当前格式数据。**`inspect()` 和 `readFrom()` 只在内存中解码。Cold `prepare()`/`load()` 先解码完整 source,补充当前 recovery closers,再用完整、平衡的当前格式 stream 替换精确的旧 revision。Live HMR adoption 在 seed 校验后使用同一个 replacement primitive,但不会为仍由 live Session 掌握的 turn 合成 closer。替换成功或 revision 冲突后都会丢弃 prepared object,重新打开持久化 source 后再继续。 -**Replacement 是 backend 内部的 compare-and-swap。**`replaceStored(expectedRevision, meta, events)` 接受流式当前格式日志,并在提交排他区内检查存储身份和 source revision。JSONL 写入并 fsync 同目录临时 artifact,在持有跨进程锁时复核 revision,原子替换路径(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录。SQLite 先暂存 event iterator,再在一个事务中复核并替换 header 与 event rows。提交失败后只会留下完整旧日志或完整新日志;永久保留升级前副本是独立的恢复策略,不属于 format migration API。 +**Replacement 是 backend 内部的 compare-and-swap。**`replaceStored(expectedRevision, meta, events)` 接受流式当前格式日志,并在提交边界检查存储身份和 source revision。JSONL 写入并 fsync 同目录临时 artifact,在原子替换路径前立即复核 source revision,然后原子替换(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录;与协调器的其他新鲜性检查一样,复核不提供跨进程写者排他——JSONL 假定每个 session 同时只有一个 live writer。SQLite 先暂存 event iterator,再在一个事务中复核并替换 header 与 event rows。提交失败后只会留下完整旧日志或完整新日志;永久保留升级前副本是独立的恢复策略,不属于 format migration API。 **逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 912ebd2152..d4d8e5c19f 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { readdirSync } from 'node:fs' -import { open, mkdir, readFile, readdir, realpath, link, rename, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, realpath, link, rename, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' @@ -44,9 +44,6 @@ const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' * remains an indivisible synchronous decode. */ const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 -const LOG_LOCK_RETRY_INITIAL_MS = 20 -const LOG_LOCK_RETRY_MAX_MS = 200 -const LOG_LOCK_TIMEOUT_MS = 2_000 const REPLACEMENT_BATCH_SIZE = 128 /** Assert that the independently decodable first frame contains only the header record. */ @@ -130,10 +127,6 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' -} - /** * The JSONL persistence backend. Load as a plugin; it registers as * `ctx.sessionPersistence` and (via the coordinator) installs the write-path @@ -484,8 +477,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { await this.ensureRootEncoding() if (isMaterialized) { - const path = logPath(this.root, meta.cwd, meta.id, this.compression) - await this.withLogLock(path, () => this.appendLines(meta, events)) + await this.appendLines(meta, events) } else { await this.materialize(meta, events) } @@ -501,12 +493,9 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi tornMarker: JsonlTornMarker | undefined, closers: readonly SessionEvent[], ): Promise { - const path = logPath(this.root, meta.cwd, meta.id, this.compression) - await this.withLogLock(path, async () => { - if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) - const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] - if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) - }) + if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) + const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] + if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) } /** Replace one exact source revision through a synced sibling and atomic namespace update. */ @@ -522,48 +511,46 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi `session "${meta.id}" no longer has revision ${expectedRevision}`, ) } - await this.withLogLock(path, async () => { - let current: JsonlStoredHeader - try { - current = await this.readStoredHeader(path, meta.id) - } catch (error: unknown) { - if (isENOENT(error)) { - throw new SessionPersistenceRevisionConflictError( - `session "${meta.id}" no longer has revision ${expectedRevision}`, - ) - } - throw error + let current: JsonlStoredHeader + try { + current = await this.readStoredHeader(path, meta.id) + } catch (error: unknown) { + if (isENOENT(error)) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" no longer has revision ${expectedRevision}`, + ) } - if (current.revision !== expectedRevision) { + throw error + } + if (current.revision !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + const currentIdentity = this.storedIdentity(current.meta, path) + if (meta.cwd !== currentIdentity.cwd) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + + const tmp = `${path}.${randomBytes(6).toString('hex')}.upgrade.tmp` + try { + await this.writeReplacement(tmp, meta, events) + const beforeCommit = fileRevision(await stat(path, { bigint: true })) + if (beforeCommit !== expectedRevision) { throw new SessionPersistenceRevisionConflictError( `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, ) } - const currentIdentity = this.storedIdentity(current.meta, path) - if (meta.cwd !== currentIdentity.cwd) { - throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + /* v8 ignore next -- Windows uses its write-through replacement primitive. */ + if (process.platform === 'win32') { + await replaceFileWin32(tmp, path) + } else { + await rename(tmp, path) + await this.syncDirPosix(dirname(path)) } - - const tmp = `${path}.${randomBytes(6).toString('hex')}.upgrade.tmp` - try { - await this.writeReplacement(tmp, meta, events) - const beforeCommit = fileRevision(await stat(path, { bigint: true })) - if (beforeCommit !== expectedRevision) { - throw new SessionPersistenceRevisionConflictError( - `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, - ) - } - /* v8 ignore next -- Windows uses its write-through replacement primitive. */ - if (process.platform === 'win32') { - await replaceFileWin32(tmp, path) - } else { - await rename(tmp, path) - await this.syncDirPosix(dirname(path)) - } - } finally { - await rm(tmp, { force: true }) - } - }) + } finally { + await rm(tmp, { force: true }) + } } /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ @@ -768,31 +755,6 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } } - /** Serialize cooperating cross-process mutations of one materialized log. */ - private async withLogLock(path: string, operation: () => Promise): Promise { - const lockPath = `${path}.lock` - const deadline = Date.now() + LOG_LOCK_TIMEOUT_MS - let delay = LOG_LOCK_RETRY_INITIAL_MS - for (;;) { - try { - await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) - break - } catch (error: unknown) { - if (!isEEXIST(error)) throw error - } - if (Date.now() >= deadline) { - throw new Error(`session log writer lock timed out at "${lockPath}"`) - } - await new Promise(resolve => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, LOG_LOCK_RETRY_MAX_MS) - } - try { - return await operation() - } finally { - await rm(lockPath, { force: true }) - } - } - /** Encode the header and first batch without combining their frame boundaries. */ private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const header = JSON.stringify(toHeaderLine(meta)) + '\n' diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index ce6e99f30c..ff88640e34 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -672,32 +672,6 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect(await collectStoredRead(replaced.readEvents())).toHaveLength(128) }) - it('retries a held writer lock and rejects lock acquisition failures and timeout', async () => { - const persistence = ctx.sessionPersistence as JsonlSessionPersistence - const internals = persistence as unknown as { - withLogLock(path: string, operation: () => Promise): Promise - } - const path = join(root, 'lock-target') - const lockPath = `${path}.lock` - await writeFile(lockPath, 'held\n') - const released = new Promise((resolveRelease) => { - setTimeout(() => { void rm(lockPath).then(() => { resolveRelease() }) }, 5) - }) - await expect(internals.withLogLock(path, async () => 'committed')).resolves.toBe('committed') - await released - - await expect(internals.withLogLock(`${root}\0invalid`, async () => undefined)) - .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) - - await writeFile(lockPath, 'held\n') - const now = vi.spyOn(Date, 'now') - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(3_001) - await expect(internals.withLogLock(path, async () => undefined)) - .rejects.toThrow(/writer lock timed out/) - now.mockRestore() - }) - it('rejects malformed version-independent storage identity fields', async () => { const persistence = ctx.sessionPersistence as JsonlSessionPersistence const path = rawLogPath(root, '/work', SessionId('identity-fields')) diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 5c14654dba..394fe18799 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -58,7 +58,7 @@ The `PersistenceBackend` hooks (the only contract between the coordi | `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `openStored` and returns `undefined` when the id is absent. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | -| `replaceStored(expectedRevision, meta, events)` | Atomically replace one exact revision with a complete current-format header and event stream. Revision and stored identity checks occur inside the same backend exclusion or transaction as the commit; a mismatch rejects with `SessionPersistenceRevisionConflictError`. | +| `replaceStored(expectedRevision, meta, events)` | Atomically replace one exact revision with a complete current-format header and event stream. Revision and stored identity checks occur at the commit boundary — immediately before the atomic rename on JSONL, inside the replacing transaction on SQLite; the checks add no cross-process writer exclusion. A mismatch rejects with `SessionPersistenceRevisionConflictError`. | | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 843dff51da..4ef93f41ac 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -58,7 +58,7 @@ v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/note | `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `openStored` 相同的修订值表示;id 不存在时返回 `undefined`。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和活动会话接管(仅截断)使用。 | -| `replaceStored(expectedRevision, meta, events)` | 用完整的当前格式 header 与事件流原子替换一个精确 revision。Revision 与存储身份检查和提交位于同一个 backend 排他区或事务内;不匹配时以 `SessionPersistenceRevisionConflictError` 拒绝。 | +| `replaceStored(expectedRevision, meta, events)` | 用完整的当前格式 header 与事件流原子替换一个精确 revision。Revision 与存储身份检查发生在提交边界——JSONL 在原子替换前立即检查,SQLite 在替换事务内检查;该检查不提供跨进程写者排他。不匹配时以 `SessionPersistenceRevisionConflictError` 拒绝。 | | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index c28f239d84..c126b0bed1 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -110,8 +110,9 @@ export interface PersistenceBackend { /** * Atomically replace one exact stored revision with a complete current log. - * The backend checks revision and storage identity in the same exclusive file - * operation or database transaction that commits the replacement. + * The backend checks revision and storage identity at the commit boundary: + * immediately before the atomic rename on JSONL, inside the replacing + * transaction on SQLite. The check adds no cross-process writer exclusion. * @param expectedRevision - exact source revision decoded by the caller. * @param meta - complete current-format header. * @param events - complete current-format event stream. From 50ad2aba19ff5973956c29d031bbe95f5a6dd1b1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 20 Aug 2026 14:21:47 +0800 Subject: [PATCH 08/15] fix(session-persistence): repair persistence CI gates - document the exported SqliteStore prefix/suffix loaders (verify-export-jsdoc) - share createStoredEventRead from session-persistence so the standalone SQLite store stops duplicating the service helper (duplication gate) - route replaceStored header upserts through writeRow (duplication gate) - move replacement/conflict test SQL into closed test resources so the SQLite SQL resource boundary test passes --- .../session-persistence-sqlite/src/store.ts | 58 +++++-------------- .../resources/sql/count-session-events.sql | 3 + .../sql/create-temp-replace-trigger.sql | 5 ++ .../resources/sql/delete-session-by-id.sql | 2 + .../sql/drop-temp-replace-trigger.sql | 1 + .../resources/sql/update-session-cwd.sql | 3 + .../resources/sql/update-session-revision.sql | 3 + .../tests/sqlite.spec.ts | 22 +++---- .../tests/test-sql.ts | 6 ++ .../session-persistence/src/format-decoder.ts | 30 ++++++++++ .../session/session-persistence/src/index.ts | 19 +----- 11 files changed, 79 insertions(+), 73 deletions(-) create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts index e1e3f8b651..02223ee780 100644 --- a/packages/session/session-persistence-sqlite/src/store.ts +++ b/packages/session/session-persistence-sqlite/src/store.ts @@ -15,6 +15,7 @@ import { type SessionHeader, } from '@deepseek-ai/dsh-session' import { + createStoredEventRead, decodeStoredSessionHeader, SessionPersistenceRevision, SessionPersistenceRevisionConflictError, @@ -22,7 +23,6 @@ import { type SessionPersistenceRevision as PersistenceRevision, type SessionPersistenceSnapshot, type StoredEventRead, - type StoredEventReadCompletion, type StoredEventReadOptions, type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' @@ -71,36 +71,6 @@ interface SqliteStoredSuffix { readonly revision: PersistenceRevision } -/** - * Build the standard lazy event stream and EOF metadata around one backend - * read, mirroring the service's protected helper for standalone stores. - * @param load - revision-checked batch loader owned by the backend. - * @param include - whether one loaded event belongs in this physical read. - * @param signal - optional cancellation checked between yielded events. - * @returns an independently consumable event read. - */ -function createStoredEventRead( - load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>, - include: (event: unknown) => boolean, - signal?: AbortSignal, -): StoredEventRead { - const completed = Promise.withResolvers>() - const events = (async function* (): AsyncIterable { - try { - const batch = await load() - for (const event of batch.events) { - signal?.throwIfAborted() - if (include(event)) yield event - } - completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })() - return { events, completed: completed.promise } -} - /** SQLite implementation of the coordinator's physical backend hooks. */ export class SqliteStore implements PersistenceBackend { readonly name = 'session-persistence-sqlite' @@ -180,6 +150,12 @@ export class SqliteStore implements PersistenceBackend { } } + /** + * Load one row's complete validated prefix at a single snapshot. + * @param id - persisted session id to resolve. + * @param signal - optional cancellation for backend read work. + * @returns the stored prefix, or `undefined` when the session has no stored row. + */ async loadStored(id: SessionId, signal?: AbortSignal): Promise { await this.observe(signal) const snapshot = this.readTransaction(() => { @@ -206,6 +182,13 @@ export class SqliteStore implements PersistenceBackend { return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row) } + /** + * Load one row's physical suffix at or past a sequence at a single snapshot. + * @param id - persisted session id to resolve. + * @param fromSeq - first physical event sequence to include. + * @param signal - optional cancellation for backend read work. + * @returns the stored suffix, or `undefined` when the session has no stored row. + */ async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { await this.observe(signal) const snapshot = this.readTransaction(() => { @@ -373,18 +356,7 @@ export class SqliteStore implements PersistenceBackend { this.db.prepare(sql('delete-events-from')).run(meta.id, 0) const insert = this.insertStatement() for (const record of records) this.insertRecord(insert, meta.id, bindRecord(record)) - this.db.prepare(sql('upsert-session')).run( - meta.id, - meta.version, - meta.createdAt, - meta.cwd ?? null, - meta.parentSession ?? null, - meta.seedLength ?? null, - meta.origin ?? null, - meta.delegationDepth ?? null, - meta.agentPreset ?? null, - randomUUID(), - ) + this.writeRow(meta) this.incrementRevision(meta.id) this.db.exec(sql('commit')) } catch (error: unknown) { diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql new file mode 100644 index 0000000000..da02575e16 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) AS n +FROM events +WHERE session_id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql new file mode 100644 index 0000000000..1fbf6ae0c7 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql @@ -0,0 +1,5 @@ +CREATE TEMP TRIGGER fail_format_replace +BEFORE UPDATE ON sessions +BEGIN + SELECT RAISE(ABORT, 'simulated format replacement failure'); +END diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql new file mode 100644 index 0000000000..afe9d6c0ec --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql @@ -0,0 +1,2 @@ +DELETE FROM sessions +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql new file mode 100644 index 0000000000..b41f647451 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql @@ -0,0 +1 @@ +DROP TRIGGER fail_format_replace; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql new file mode 100644 index 0000000000..7586325b16 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql @@ -0,0 +1,3 @@ +UPDATE sessions +SET cwd = ? +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql new file mode 100644 index 0000000000..2cfbcb2b82 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql @@ -0,0 +1,3 @@ +UPDATE sessions +SET revision = revision + 1 +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 448bff85ee..55d6e26862 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -871,7 +871,7 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const removed = await store.openStored(m.id) if (removed === undefined) throw new Error('test session must remain materialized') const db = (store as unknown as { db: DatabaseSync }).db - db.prepare('DELETE FROM sessions WHERE id = ?').run(m.id) + db.prepare(testSql('delete-session-by-id')).run(m.id) const removedRead = removed.readEvents({ fromSeq: 1 }) const removedCompletion = removedRead.completed.catch((error: unknown) => error) await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })()) @@ -896,7 +896,7 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () }) await expect(store.loadStoredFrom(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure') spy.mockRestore() - expect((db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n) .toBe(oneTurnLog().length) await store.close() }) @@ -944,11 +944,11 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const db = (store as unknown as { db: DatabaseSync }).db const changesDuringStaging = (async function* (): AsyncIterable { yield* oneTurnLog() - db.prepare('UPDATE sessions SET cwd = ? WHERE id = ?').run('/raced', m.id) + db.prepare(testSql('update-session-cwd')).run('/raced', m.id) })() await expect(store.replaceStored(source.revision, m, changesDuringStaging)) .rejects.toThrow(/changes its stored identity/) - expect((db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n) .toBe(oneTurnLog().length) await store.close() }) @@ -963,12 +963,12 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const db = (store as unknown as { db: DatabaseSync }).db const changesDuringStaging = (async function* (): AsyncIterable { yield* oneTurnLog() - db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(m.id) + db.prepare(testSql('update-session-revision')).run(m.id) })() await expect(store.replaceStored(source.revision, m, changesDuringStaging)) .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) - expect((db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n) .toBe(oneTurnLog().length) await store.close() }) @@ -981,18 +981,12 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const source = await store.openStored(m.id) if (source === undefined) throw new Error('test session must be materialized') const db = (store as unknown as { db: DatabaseSync }).db - db.exec(` - CREATE TEMP TRIGGER fail_format_replace - BEFORE UPDATE ON sessions - BEGIN - SELECT RAISE(ABORT, 'simulated format replacement failure'); - END - `) + db.exec(testSql('create-temp-replace-trigger')) await expect( store.replaceStored(source.revision, m, replacementEvents([])), ).rejects.toThrow(/simulated format replacement failure/) - db.exec('DROP TRIGGER fail_format_replace') + db.exec(testSql('drop-temp-replace-trigger')) expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog()) await store.close() diff --git a/packages/session/session-persistence-sqlite/tests/test-sql.ts b/packages/session/session-persistence-sqlite/tests/test-sql.ts index 77b53a404e..beb11d6f65 100644 --- a/packages/session/session-persistence-sqlite/tests/test-sql.ts +++ b/packages/session/session-persistence-sqlite/tests/test-sql.ts @@ -8,10 +8,14 @@ export type TestSqlName = | 'count-ignorable-events' | 'count-packed-events' | 'count-physical-types' + | 'count-session-events' | 'create-loose-schema' + | 'create-temp-replace-trigger' | 'create-unrelated-table' | 'delete-persistence-state' + | 'delete-session-by-id' | 'delete-session-events' + | 'drop-temp-replace-trigger' | 'empty-store-id' | 'insert-corrupt-event' | 'measure-write-traffic' @@ -25,6 +29,8 @@ export type TestSqlName = | 'set-user-version-16' | 'set-user-version-17' | 'update-invalid-session-metadata' + | 'update-session-cwd' + | 'update-session-revision' /** Load one fixed test SQL resource. */ export function testSql(name: TestSqlName): string { diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index 3a136441d0..b5e38c4e98 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -89,6 +89,36 @@ export interface StoredSessionSource { readEvents(options?: StoredEventReadOptions): StoredEventRead } +/** + * Build the standard lazy event stream and EOF metadata around one backend + * read, shared by every first-party backend. + * @param load - revision-checked batch loader owned by the backend. + * @param include - whether one loaded event belongs in this physical read. + * @param signal - optional cancellation checked between yielded events. + * @returns an independently consumable event read. + */ +export function createStoredEventRead( + load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>, + include: (event: unknown) => boolean, + signal?: AbortSignal, +): StoredEventRead { + const completed = Promise.withResolvers>() + const events = (async function* (): AsyncIterable { + try { + const batch = await load() + for (const event of batch.events) { + signal?.throwIfAborted() + if (include(event)) yield event + } + completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })() + return { events, completed: completed.promise } +} + /** One decoded current-format read bound to an exact stored revision. */ export interface DecodedSession { /** Validated current-format header. */ diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 297a768b75..1fd2c3a73d 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -8,7 +8,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { SessionPreparation, type SessionEvent, type SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' -import type { StoredEventRead, StoredEventReadCompletion } from './format-decoder.ts' +import { createStoredEventRead, type StoredEventRead } from './format-decoder.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' @@ -53,6 +53,7 @@ export type { PersistenceCoordinatorOptions, } from './coordinator.ts' export { + createStoredEventRead, decodeStoredSessionHeader, SessionFormatUnsupportedError, sessionFormatVersionRefusal, @@ -98,21 +99,7 @@ export abstract class SessionPersistence extends Service { include: (event: unknown) => boolean, signal?: AbortSignal, ): StoredEventRead { - const completed = Promise.withResolvers>() - const events = (async function* (): AsyncIterable { - try { - const batch = await load() - for (const event of batch.events) { - signal?.throwIfAborted() - if (include(event)) yield event - } - completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })() - return { events, completed: completed.promise } + return createStoredEventRead(load, include, signal) } /** From a693e0764b549c9f283ef847c77687e08190709b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 13:09:33 +0800 Subject: [PATCH 09/15] docs: revert spurious BRAND-GUIDELINES.md change from master merge --- BRAND-GUIDELINES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BRAND-GUIDELINES.md b/BRAND-GUIDELINES.md index 96ecc5d922..7220a570a0 100644 --- a/BRAND-GUIDELINES.md +++ b/BRAND-GUIDELINES.md @@ -18,4 +18,5 @@ To maintain the long\-term healthy development of the DeepSeek Harness ecosystem - When naming your project, please avoid using the full "DeepSeek Harness" trademark directly\. "DeepSeek Harness" is a registered trademark of DeepSeek\. Unauthorized use in project names can easily lead to user misunderstanding and confusion, thereby affecting the clarity of the entire ecosystem\. It may also involve trademark infringement\. - Additionally, please avoid using official brand materials in your promotions or presentations in a way that could cause misunderstanding, so as not to give users the false impression of official endorsement, cooperation, or authorization\. -We believe that a clear and orderly community environment will make every developer's efforts more visible and more readily recognized\. For the few cases that do not comply with the above specifications, we may contact the relevant parties to make appropriate adjustments in order to maintain the overall order of the ecosystem\. Thank you for your understanding and support—let us work together to build a more friendly and sustainable open\-source community\. \ No newline at end of file +We believe that a clear and orderly community environment will make every developer's efforts more visible and more readily recognized\. For the few cases that do not comply with the above specifications, we may contact the relevant parties to make appropriate adjustments in order to maintain the overall order of the ecosystem\. Thank you for your understanding and support—let us work together to build a more friendly and sustainable open\-source community\. + From 2da00047f3348ebdf0c2cd979c11c4c85063c938 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 15:50:30 +0800 Subject: [PATCH 10/15] refactor(session-persistence): make format migrations one-to-one --- ...10-session-log-version-mechanism.i18n.yaml | 4 +- ...026-08-10-session-log-version-mechanism.md | 8 +- ...-08-10-session-log-version-mechanism.zh.md | 8 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 2 +- docs/subsystems/persistence.zh.md | 2 +- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 8 +- .../session/session-persistence/README.zh.md | 8 +- .../session-persistence/src/format-decoder.ts | 167 ++++---- .../src/format-migrations/index.ts | 8 +- .../session/session-persistence/src/index.ts | 3 +- .../tests/format-decoder.spec.ts | 377 ++++++++++-------- 13 files changed, 344 insertions(+), 259 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index 1ce9bd62d0..ee8c71110e 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 8515d04ebfbd9e23ff5b58ca8b4bb888f8ba5dc9 -2026-08-10-session-log-version-mechanism.zh.md: 04e66b5da3a14d1c3f919cb5cdb8ded0f76a2baf +2026-08-10-session-log-version-mechanism.md: dfbe5c1926cf683a34ec6694f188f57c44b9ca10 +2026-08-10-session-log-version-mechanism.zh.md: 00d58757d3ea4bf1689a0847613557613d40ebf6 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 8515d04ebf..dfbe5c1926 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -14,11 +14,11 @@ Session logs must be upgradable after release, and the runtime that ships first **The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers. -**Read rules by direction.** Equal version: decode normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: require a complete chain of static n→n+1 `SessionFormatStep`s; a missing step refuses the read and names the gap. The registry is part of the build rather than Cordis composition, so one build has the same durable read capability under every plugin set. +**Read rules by direction.** Equal version: decode normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: require a complete chain of static n→n+1 `SessionFormatMigration` classes; a missing migration refuses the read and names the gap. The registry is part of the build rather than Cordis composition, so one build has the same durable read capability under every plugin set. -**Format migration is the decoder, not a Coordinator repair branch.** Backends expose parsed durable data as `unknown` through a repeatable `StoredSessionSource`: one raw header, one exact revision, and `readEvents()` factories that create independently consumable `AsyncIterable` streams bound to that revision. Each step implements `migrateHeader()` and lazy `migrateEvents()` transforms, must advance exactly one version, and may not change the session id or cwd. Any version conversion reads the complete event stream and applies the requested suffix only after all steps; an equal-version read retains backend suffix seek. The decoder validates each output header version, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain. +**Format migration is the decoder, not a Coordinator repair branch.** Backends expose parsed durable data as `unknown` through a repeatable `StoredSessionSource`: one raw header, one exact revision, and `readEvents()` factories that create independently consumable `AsyncIterable` streams bound to that revision. Each migration class carries static adjacent `from`/`to` versions. One fresh instance handles one decode attempt: `header()` runs once, `event()` maps each input record to exactly one lossless-JSON output with the same seq, and optional `finish()` validates accumulated state after EOF. Instance fields may retain header and earlier-event facts without sharing state across sessions, concurrent reads, or revision retries. Header-only reads stop after `header()` and never call `finish()`, so that method validates EOF state rather than releasing resources. Any version conversion reads the complete event stream and applies the requested suffix only after all migrations; an equal-version read retains backend suffix seek. The decoder validates each output header version and each migration's seq preservation, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain. -**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version steps. This decoder maps the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to canonical `compaction/*` events while preserving the rest of each record. +**A future format bump adds one format-owned migration.** The change adds `format-migrations/vN-to-vN+1.ts`, exports its class from the static `SESSION_FORMAT_MIGRATIONS` array, and increments `SESSION_FORMAT_VERSION`. The migration owns every old header and event variant it accepts, its instance state, and explicit failure for malformed input. It cannot add, remove, reorder, or renumber events: durable references use seq as event identity. A format change that alters facts consumed by a projection increments that projection's `stateVersion`; unchanged projections retain their cache rows. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version migrations. This decoder maps the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to canonical `compaction/*` events while preserving the rest of each record. **Recovery and writeback consume current-format data.** `inspect()` and `readFrom()` decode only in memory. Cold `prepare()`/`load()` first decode the whole source, add the current recovery closers, and replace the exact old revision with that complete balanced current-format stream. Live HMR adoption uses the same replacement primitive after seed verification but does not synthesize closers for a turn still owned by the live Session. A successful replacement or revision conflict discards the prepared object and reopens the stored source before continuing. @@ -36,6 +36,6 @@ Format v0 carries direction-aware refusal with the raw-log path; the unknown-eve - **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption. - **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked. - **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists. -- **Materializing migrations as header and event arrays** — makes the framework proportional to complete log size in memory even when each transformation is record-local. Repeatable revision-bound readers plus iterator transforms preserve retry semantics without imposing that allocation. +- **Materializing migrations as header and event arrays** — makes the framework proportional to complete log size in memory even when each transformation is record-local. Repeatable revision-bound readers plus one-at-a-time event transforms preserve retry semantics without imposing that allocation. - **Version-specific conversion in `PersistenceCoordinator`** — mixes format decoding with operation-specific crash recovery and duplicates behavior across inspect, suffix read, cold continuation, and live adoption. The shared decoder produces only current-format data; each consumer retains its own recovery intent. - **A mandatory permanent backup for every upgrade** — is not needed for atomicity and cannot promise the same physical representation across JSONL and SQLite. Backends may add recovery copies as a separate product policy without changing migrations. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index 04e66b5da3..00d58757d3 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -14,11 +14,11 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 -**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatStep` 组成完整链路,缺失任何一步都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。 +**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatMigration` 类组成完整链路,缺失任何 migration 都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。 -**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每一步实现 `migrateHeader()` 和惰性的 `migrateEvents()` 转换,只能前进一个版本,也不能改变 Session id 或 cwd。只要发生版本转换,就读取完整事件流,并在所有步骤完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。 +**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每个 migration class 用静态且相邻的 `from`/`to` 标识版本。每次 decode 都创建一个新实例:`header()` 调用一次;`event()` 把每条输入记录映射为一条 seq 相同、可无损表示为 JSON 的输出;可选的 `finish()` 在 EOF 后验证累计状态。实例字段可以保留 header 与之前事件的事实,而不会在 Session、并发读取或 revision retry 之间共享状态。只读 header 时在 `header()` 后结束,绝不调用 `finish()`,因此该方法用于验证 EOF 状态而不是释放资源。只要发生版本转换,就读取完整事件流,并在所有 migration 完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version 和每个 migration 是否保持 seq,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。 -**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。该 decoder 将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称映射为规范的 `compaction/*` 事件,并保留每条记录的其余内容。 +**以后每次 format bump 只增加一个格式 migration。**改动新增 `format-migrations/vN-to-vN+1.ts`,把它的 class 导出到静态 `SESSION_FORMAT_MIGRATIONS` 数组,并递增 `SESSION_FORMAT_VERSION`。Migration 自己负责它接受的所有旧 header 和 event 变体、实例状态,以及对畸形输入的明确失败。它不能增加、删除、重排事件或重编号:持久引用以 seq 作为事件身份。如果格式变化影响了某个 projection 消费的事实,就递增该 projection 的 `stateVersion`;未受影响的 projection 保留 cache 记录。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本 migration 的模板。该 decoder 将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称映射为规范的 `compaction/*` 事件,并保留每条记录的其余内容。 **Recovery 和写回只消费当前格式数据。**`inspect()` 和 `readFrom()` 只在内存中解码。Cold `prepare()`/`load()` 先解码完整 source,补充当前 recovery closers,再用完整、平衡的当前格式 stream 替换精确的旧 revision。Live HMR adoption 在 seed 校验后使用同一个 replacement primitive,但不会为仍由 live Session 掌握的 turn 合成 closer。替换成功或 revision 冲突后都会丢弃 prepared object,重新打开持久化 source 后再继续。 @@ -36,6 +36,6 @@ Format v0 包含:分方向的拒绝并带原始日志路径;基于生成的 - **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 - **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。 - **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。 -- **把 migration 物化为 header 和 event 数组**:即使每步转换只依赖单条 record,也会让框架内存占用与完整日志大小成正比。可重复、绑定 revision 的 reader 加 iterator 转换保留重试语义,又不强制这笔分配。 +- **把 migration 物化为 header 和 event 数组**:即使每步转换只依赖单条 record,也会让框架内存占用与完整日志大小成正比。可重复、绑定 revision 的 reader 加逐事件转换保留重试语义,又不强制这笔分配。 - **在 `PersistenceCoordinator` 内写版本转换**:会把格式解码和各操作不同的 crash recovery 混在一起,并在 inspect、suffix read、cold continuation 和 live adoption 间复制行为。共享 decoder 只产出当前格式数据,各 consumer 保留自己的 recovery intent。 - **每次升级都强制永久备份**:原子性不依赖永久副本,而且 JSONL 与 SQLite 无法承诺相同的物理表示。Backend 可以把恢复副本作为独立产品策略加入,不需要修改 migration。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index baa245bcd8..31542655a8 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: ef193806ce6234d1c25e2118ca2db7233c0b9b2e -persistence.zh.md: 5fa47497249888f478e471e2798b6dfcc724db84 +persistence.md: 7a159b37a561977f4af20dae1097eef8dc7df343 +persistence.zh.md: a8a306d025f73eb221dc456a605bd934510fd39f diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index ef193806ce..7a159b37a5 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -381,5 +381,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 5fa4749724..a8a306d025 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -381,5 +381,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index cb4e019406..8a7e0c383a 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 394fe18799191f9a190a3e2f9ed68aa740bf6695 -README.zh.md: 4ef93f41ac8b88843e14d6e8ce14f147a79a508e +README.md: 4a3111f8e3d38add9204b121dd100c9fa5a78d7c +README.zh.md: 02010582fd04afe8dd45975d4fb080fd5288733e diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 394fe18799..4a3111f8e3 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -18,7 +18,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | | `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after decoding a supported format path and committing any format replacement plus cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Current-format reads request a suffix from the backend; a format step requires the complete source and applies `fromSeq` only after migration. Sequential media may still scan framing before filtering, while seek-capable media can avoid reading earlier rows. Intended for checkpoint consumers that apply only events after a stored sequence number. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Current-format reads request a suffix from the backend; a format migration requires the complete source and applies `fromSeq` only after migration. Sequential media may still scan framing before filtering, while seek-capable media can avoid reading earlier rows. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -39,11 +39,11 @@ Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative ## Format decoding and upgrades -Every logical read opens a repeatable `StoredSessionSource` containing an untrusted header, an exact revision, and a `readEvents()` factory. The static decoder chooses a complete adjacent-version path, streams each `migrateEvents()` transform, and validates the final header and events as the current format. `inspect()` and `readFrom()` do not write. Cold continuation and live adoption replace a converted source through the backend's revision compare-and-swap, then reopen it; a concurrent change discards the decoded result and restarts from the new source. The [session-log versioning Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns the rationale and refusal rules. +Every logical read opens a repeatable `StoredSessionSource` containing an untrusted header, an exact revision, and a `readEvents()` factory. The static decoder chooses a complete adjacent-version path, creates one migration instance per version, calls `header()` once, calls `event()` once per input record, and calls optional `finish()` after EOF. It then validates the final header and events as the current format. `inspect()` and `readFrom()` do not write. Cold continuation and live adoption replace a converted source through the backend's revision compare-and-swap, then reopen it; a concurrent change discards the decoded result and restarts from the new source. The [session-log versioning Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns the rationale and refusal rules. -A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports the step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step validates every accepted vN header/event variant, preserves id and cwd, returns header version N+1, and keeps any cross-event state inside its iterator. Backends and the coordinator remain version-independent. +A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports its class from the static `SESSION_FORMAT_MIGRATIONS` array, and increments `SESSION_FORMAT_VERSION`. Static `from`/`to` identify adjacent versions; instance fields retain header and cross-event state. `header()` validates and converts the old header, `event()` returns exactly one lossless-JSON event with the input event's seq, and optional `finish()` validates state that can be settled only at EOF. Header-only reads do not call `finish()`. A migration that changes facts consumed by a projection also increments that projection's `stateVersion`; persistence does not invalidate every projection cache entry. Backends and the coordinator remain version-independent. -The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, and normalizes the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to their canonical `compaction/*` names. These compatibility transforms are not version steps. +The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, and normalizes the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to their canonical `compaction/*` names. These compatibility transforms are not format migrations. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 4ef93f41ac..02010582fd 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -18,7 +18,7 @@ | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose(资源释放)时将未发布 reservation 释放回有界缓存。 | | `load(id): Promise<{ meta; events }>` | 沿受支持的格式路径解码,并提交格式替换与冷恢复后,返回不可变、平衡的逻辑日志。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。当前格式读取向后端请求 suffix;存在格式步骤时则读取完整 source,迁移后才应用 `fromSeq`。顺序介质可能仍需扫描物理 framing 后再过滤,可寻址介质则可不读取更早的记录。供 checkpoint 消费方只应用已存序号之后的事件。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。当前格式读取向后端请求 suffix;存在格式迁移时则读取完整 source,迁移后才应用 `fromSeq`。顺序介质可能仍需扫描物理 framing 后再过滤,可寻址介质则可不读取更早的记录。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -39,11 +39,11 @@ ## 格式解码与升级 -每次逻辑读取都会打开可重复使用的 `StoredSessionSource`,其中包含不可信 header、精确 revision 和 `readEvents()` factory。静态 decoder 选择完整的相邻版本路径,以流式方式执行各个 `migrateEvents()` 转换,最后按当前格式验证 header 与事件。`inspect()` 和 `readFrom()` 不写存储;冷 continuation 与实时接管通过后端的 revision compare-and-swap 替换已转换 source,然后重新打开。并发变更会丢弃解码结果,并从新 source 重新开始。[Session log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)规定其原因和拒绝规则。 +每次逻辑读取都会打开可重复使用的 `StoredSessionSource`,其中包含不可信 header、精确 revision 和 `readEvents()` factory。静态 decoder 选择完整的相邻版本路径,为每个版本创建一个 migration 实例,调用一次 `header()`,为每条输入记录调用一次 `event()`,并在 EOF 后调用可选的 `finish()`,最后按当前格式验证 header 与事件。`inspect()` 和 `readFrom()` 不写存储;冷 continuation 与实时接管通过后端的 revision compare-and-swap 替换已转换 source,然后重新打开。并发变更会丢弃解码结果,并从新 source 重新开始。[Session log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)规定其原因和拒绝规则。 -以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加步骤,从静态 `SESSION_FORMAT_STEPS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。该步骤验证它接受的所有 vN header/event 变体,保持 id 与 cwd 不变,返回 version 为 N+1 的 header,并把跨事件状态保留在自己的 iterator 中。后端和协调器不增加版本特判。 +以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加 class,从静态 `SESSION_FORMAT_MIGRATIONS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。静态 `from`/`to` 标识相邻版本,实例字段保留 header 和跨事件状态。`header()` 验证并转换旧 header;`event()` 只返回一条可无损表示为 JSON 且 seq 与输入相同的事件;可选的 `finish()` 验证只能在 EOF 时结算的状态。只读 header 时不调用 `finish()`。如果 migration 改变了某个 projection 消费的事实,还要递增该 projection 的 `stateVersion`;persistence 不统一作废所有 projection cache 记录。后端和协调器不增加版本特判。 -v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体,并将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称归一化为规范的 `compaction/*` 名称。这些兼容转换不是版本步骤。 +v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体,并将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称归一化为规范的 `compaction/*` 名称。这些兼容转换不是格式迁移。 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index b5e38c4e98..19eb383a03 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -19,37 +19,45 @@ import { import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' import { asStoredRecord, assertNoRetiredSessionEvent, readStoredEventEnvelope } from './format-json.ts' import type { SessionLocation } from './index.ts' -import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' -import { SessionPersistenceRevisionConflictError, type SessionPersistenceRevision } from './revision.ts' +import { SESSION_FORMAT_MIGRATIONS } from './format-migrations/index.ts' +import type { SessionPersistenceRevision } from './revision.ts' -/** Stable facts available to one format step invocation. */ -export interface SessionFormatContext { - /** Session identity read from the source header. */ - readonly sessionId: SessionId +/** One single-use adjacent-version migration instance. */ +interface SessionFormatMigrationInstance { + /** + * Transform and validate the header fields understood by this migration. + * The detached result must carry the constructor's `to` version and preserve + * the source id and cwd. + * @param meta - detached input header for the constructor's `from` version. + * @returns detached header JSON carrying the constructor's `to` version. + */ + header(meta: unknown): unknown + /** + * Transform exactly one event while retaining its sequence number. Instance + * fields may accumulate facts from the header and earlier events. + * @param event - detached input event in durable sequence order. + * @returns exactly one detached event for the same sequence number. + */ + event(event: unknown): unknown + /** + * Validate accumulated state after the complete input stream reaches EOF. + * Header-only reads do not call this method; it cannot emit another event. + */ + finish?(): void } -/** One static adjacent-version transform in the durable format decoder. */ -export interface SessionFormatStep { +/** Static identity and constructor for one adjacent-version migration. */ +export interface SessionFormatMigration { /** Input Session format version. */ readonly from: number /** Output Session format version; must equal `from + 1`. */ readonly to: number /** - * Transform and validate the header fields understood by this step. The - * detached result must carry {@link to} and preserve the source id and cwd. - * @param meta - detached input header for {@link from}. - * @param context - stable session identity. - * @returns detached header JSON carrying {@link to}. + * Create fresh state for one header decode and its optional complete event + * stream. Instances are never shared across sessions or decode attempts. + * @returns a single-use migration instance. */ - migrateHeader(meta: unknown, context: SessionFormatContext): unknown - /** - * Lazily transform and validate events understood by this step. The output - * must be detached, losslessly JSON-serializable, and contiguous by seq. - * @param events - detached input records in durable sequence order. - * @param context - stable session identity. - * @returns a lazy output stream in the next format. - */ - migrateEvents(events: AsyncIterable, context: SessionFormatContext): AsyncIterable + new(): SessionFormatMigrationInstance } /** Options for one physical event read. */ @@ -123,7 +131,7 @@ export function createStoredEventRead( export interface DecodedSession { /** Validated current-format header. */ readonly meta: SessionHeader - /** Version observed before any format step ran. */ + /** Version observed before any format migration ran. */ readonly sourceVersion: number /** Exact backend revision represented by this source. */ readonly revision: SessionPersistenceRevision @@ -161,33 +169,37 @@ export function sessionFormatVersionRefusal(id: string, version: number): string : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` } -function buildStepIndex(steps: readonly SessionFormatStep[]): ReadonlyMap { - const byFrom = new Map() - for (const step of steps) { - if (!Number.isSafeInteger(step.from) || step.from < 0 || step.to !== step.from + 1) { - throw new TypeError(`Session format step must be an adjacent non-negative version, got v${step.from} -> v${step.to}`) +function buildMigrationIndex( + migrations: readonly SessionFormatMigration[], +): ReadonlyMap { + const byFrom = new Map() + for (const Migration of migrations) { + if (!Number.isSafeInteger(Migration.from) || Migration.from < 0 || Migration.to !== Migration.from + 1) { + throw new TypeError(`Session format migration must be an adjacent non-negative version, got v${Migration.from} -> v${Migration.to}`) } - if (byFrom.has(step.from)) { - throw new TypeError(`duplicate Session format step from v${step.from}`) + if (byFrom.has(Migration.from)) { + throw new TypeError(`duplicate Session format migration from v${Migration.from}`) } - if (step.to > SESSION_FORMAT_VERSION) { - throw new TypeError(`Session format step v${step.from} -> v${step.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`) + if (Migration.to > SESSION_FORMAT_VERSION) { + throw new TypeError(`Session format migration v${Migration.from} -> v${Migration.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`) } - byFrom.set(step.from, step) + byFrom.set(Migration.from, Migration) } - // A missing step is a per-session concern, decided by planSteps() at decode + // A missing migration is a per-session concern, decided by planMigrations() at decode // time: it refuses sessions at or below the gap, while later versions whose // path to the current version is complete still upgrade. Initialization - // therefore checks only step legality and duplicates here. + // therefore checks only migration legality and duplicates here. return byFrom } -const STEP_BY_FROM = buildStepIndex(SESSION_FORMAT_STEPS) +const MIGRATION_BY_FROM = buildMigrationIndex(SESSION_FORMAT_MIGRATIONS) + +type PlannedMigration = readonly [SessionFormatMigration, SessionFormatMigrationInstance] interface DecodedHeader { readonly meta: SessionHeader readonly sourceVersion: number - readonly steps: readonly SessionFormatStep[] + readonly migrations: readonly PlannedMigration[] readonly unversionedCompatibility?: UnversionedFormatCompatibility } @@ -229,23 +241,23 @@ function readSourceHeader( return { meta, version, id } } -function planSteps( +function planMigrations( source: StoredHeaderSource, id: SessionId, fromVersion: number, -): readonly SessionFormatStep[] { - const steps: SessionFormatStep[] = [] +): readonly SessionFormatMigration[] { + const migrations: SessionFormatMigration[] = [] for (let version = fromVersion; version < SESSION_FORMAT_VERSION; version++) { - const step = STEP_BY_FROM.get(version) - if (step === undefined) { + const Migration = MIGRATION_BY_FROM.get(version) + if (Migration === undefined) { throw unsupported( source, `session "${id}" uses log format v${fromVersion}, older than the supported v${SESSION_FORMAT_VERSION}, and this build has no upgrade path to it: missing v${version} -> v${version + 1}`, ) } - steps.push(step) + migrations.push(Migration) } - return steps + return migrations } function decodeHeader( @@ -253,35 +265,37 @@ function decodeHeader( expectedId: SessionId, ): DecodedHeader { const stored = readSourceHeader(source, expectedId) - const steps = planSteps(source, stored.id, stored.version) + const migrations: PlannedMigration[] = [] let meta: unknown = stored.meta - for (const step of steps) { - const context: SessionFormatContext = { sessionId: stored.id } + for (const Migration of planMigrations(source, stored.id, stored.version)) { + let instance: SessionFormatMigrationInstance try { - meta = snapshotJsonValue(step.migrateHeader(meta, context)) + instance = new Migration() + meta = snapshotJsonValue(instance.header(meta)) } catch (error: unknown) { throw new Error( - `session "${stored.id}" header migration v${step.from} -> v${step.to} failed`, + `session "${stored.id}" header migration v${Migration.from} -> v${Migration.to} failed`, { cause: error }, ) } const record = asStoredRecord(meta) const actual = record?.['version'] - if (actual !== step.to) { - throw new Error(`Session format step v${step.from} -> v${step.to} returned header version ${String(actual)}`) + if (actual !== Migration.to) { + throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} returned header version ${String(actual)}`) } if (record === undefined || record['id'] !== stored.id || record['cwd'] !== stored.meta['cwd']) { - throw new Error(`Session format step v${step.from} -> v${step.to} changed session storage identity`) + throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} changed session storage identity`) } + migrations.push([Migration, instance]) } const current = Session.create(stored.id, undefined, meta as SessionHeader).header const compatibility = unversionedFormatCompatibility(stored.version) return { meta: current, sourceVersion: stored.version, - steps, + migrations, ...(compatibility === undefined ? {} : { unversionedCompatibility: compatibility }), } } @@ -339,28 +353,41 @@ async function* decodeCurrentEvents( } } -function transformEvents( +async function* transformEvents( events: AsyncIterable, - steps: readonly SessionFormatStep[], + migrations: readonly PlannedMigration[], id: SessionId, ): AsyncIterable { - let transformed = events - for (const step of steps) { - const input = transformed - const context: SessionFormatContext = { sessionId: id } - transformed = (async function* (): AsyncIterable { + for await (let value of events) { + for (const [Migration, instance] of migrations) { + const sourceSeq = asStoredRecord(value)?.['seq'] + let output: unknown try { - yield* step.migrateEvents(input, context) + output = instance.event(value) } catch (error: unknown) { - if (error instanceof SessionPersistenceRevisionConflictError) throw error throw new Error( - `session "${id}" event migration v${step.from} -> v${step.to} failed`, + `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at seq ${String(sourceSeq)}`, { cause: error }, ) } - })() + const targetSeq = asStoredRecord(output)?.['seq'] + if (targetSeq !== sourceSeq) { + throw new Error(`session "${id}" event migration v${Migration.from} -> v${Migration.to} changed event seq ${String(sourceSeq)} to ${String(targetSeq)}`) + } + value = output + } + yield value + } + for (const [Migration, instance] of migrations) { + try { + instance.finish?.() + } catch (error: unknown) { + throw new Error( + `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at EOF`, + { cause: error }, + ) + } } - return transformed } async function* snapshotStoredEvents( @@ -385,7 +412,7 @@ function decodedRead( readonly completed: Promise> } { const completion = Promise.withResolvers>() - const migrating = header.steps.length > 0 + const migrating = header.migrations.length > 0 const compatibility = header.unversionedCompatibility let physical: StoredEventRead | undefined @@ -420,7 +447,7 @@ function decodedRead( : compatibility.canonicalizeEvents(storedEvents, header.meta.id) const transformed = transformEvents( canonicalEvents, - header.steps, + header.migrations, header.meta.id, ) const current = decodeCurrentEvents(source, header.meta, transformed, physicalFromSeq) @@ -438,9 +465,9 @@ function decodedRead( } /** - * Decode one backend source through the static adjacent-version - * steps and the current header/event validators. Format selection is complete - * before any consumer-specific recovery runs. + * Decode one backend source through the static adjacent-version migrations and + * the current header/event validators. Format selection is complete before any + * consumer-specific recovery runs. * @param source - backend-owned header, revision, and event reader factory. * @param expectedId - session identity selected by the caller. * @param fromSeq - first current-format event sequence to return. diff --git a/packages/session/session-persistence/src/format-migrations/index.ts b/packages/session/session-persistence/src/format-migrations/index.ts index ace5670d45..ecdce5e841 100644 --- a/packages/session/session-persistence/src/format-migrations/index.ts +++ b/packages/session/session-persistence/src/format-migrations/index.ts @@ -1,6 +1,6 @@ -/** Static adjacent-version Session format steps shipped by this build. */ +/** Static adjacent-version Session format migrations shipped by this build. */ -import type { SessionFormatStep } from '../format-decoder.ts' +import type { SessionFormatMigration } from '../format-decoder.ts' -/** Ordered durable format steps; format v0 is current, so the chain is empty. */ -export const SESSION_FORMAT_STEPS: readonly SessionFormatStep[] = Object.freeze([]) +/** Ordered durable format migrations; format v0 is current, so the chain is empty. */ +export const SESSION_FORMAT_MIGRATIONS: readonly SessionFormatMigration[] = Object.freeze([]) diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 1fd2c3a73d..91cabdaf99 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -259,8 +259,7 @@ export abstract class SessionPersistence extends Service { export default SessionPersistence export type { - SessionFormatContext, - SessionFormatStep, + SessionFormatMigration, StoredEventRead, StoredEventReadCompletion, StoredEventReadOptions, diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 30004d4f76..5ba23fab17 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -6,8 +6,7 @@ import { SessionPersistenceRevisionConflictError, } from '../src/revision.ts' import type { - SessionFormatContext, - SessionFormatStep, + SessionFormatMigration, StoredEventReadCompletion, StoredSessionSource, } from '../src/format-decoder.ts' @@ -15,6 +14,7 @@ import { sessionFormatVersionRefusal } from '../src/format-decoder.ts' import { unversionedFormatCompatibility } from '../src/format-v0-compat.ts' const id = SessionId('format-migration') +type SessionFormatMigrationInstance = InstanceType function eventLog(): SessionEvent[] { return [ @@ -72,47 +72,69 @@ function storedSource( } } +function defineMigration( + from: number, + create: () => SessionFormatMigrationInstance, + to = from + 1, +): SessionFormatMigration { + return class implements SessionFormatMigrationInstance { + static readonly from = from + static readonly to = to + + private readonly delegate = create() + + header(meta: unknown): unknown { + return this.delegate.header(meta) + } + + event(value: unknown): unknown { + return this.delegate.event(value) + } + + finish(): void { + this.delegate.finish?.() + } + } +} + function migration( from: number, calls: string[], -): SessionFormatStep { - return { - from, - to: from + 1, - migrateHeader(meta) { - calls.push(`header:${from}`) - return { ...(meta as Record), version: from + 1 } - }, - migrateEvents(events) { - return (async function* (): AsyncIterable { - let observedInput = false - for await (const value of events) { - if (!observedInput) { - calls.push(`events:${from}`) - observedInput = true - } - const event = value as SessionEvent - const data = event.data as Record - const migrationPath = Array.isArray(data['migrationPath']) - ? data['migrationPath'] as unknown[] - : [] - yield { - ...event, - data: { - ...data, - [`migratedFrom${from}`]: true, - migrationPath: [...migrationPath, from], - }, - } + to = from + 1, +): SessionFormatMigration { + return defineMigration(from, () => { + let observedInput = false + return { + header(meta) { + calls.push(`header:${from}`) + return { ...(meta as Record), version: to } + }, + event(value) { + if (!observedInput) { + calls.push(`events:${from}`) + observedInput = true } - })() - }, - } + const event = value as SessionEvent + const data = event.data as Record + const migrationPath = Array.isArray(data['migrationPath']) + ? data['migrationPath'] as unknown[] + : [] + return { + ...event, + data: { + ...data, + [`migratedFrom${from}`]: true, + migrationPath: [...migrationPath, from], + }, + } + }, + } + }, to) } async function configuredDecoder( currentVersion: number, - migrations: readonly SessionFormatStep[], + migrations: readonly SessionFormatMigration[], calls: string[] = [], ): Promise<{ decodeStoredSession: typeof import('../src/format-decoder.ts')['decodeStoredSession'] @@ -143,7 +165,7 @@ async function configuredDecoder( } }) vi.doMock('../src/format-migrations/index.ts', () => ({ - SESSION_FORMAT_STEPS: migrations, + SESSION_FORMAT_MIGRATIONS: migrations, })) const decoder = await import('../src/format-decoder.ts') return { @@ -196,21 +218,20 @@ describe('versioned Session format decoder', { concurrent: false }, () => { }) it('lets an old-format suffix migration use facts from events before fromSeq', async () => { - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => (async function* (): AsyncIterable { - let previousSeq: number | undefined - for await (const value of events) { + const step = defineMigration(0, () => { + let previousSeq: number | undefined + return { + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { const event = value as SessionEvent - yield previousSeq === undefined + const migrated = previousSeq === undefined ? event : { ...event, data: { ...event.data, previousSeq } } previousSeq = event.seq - } - })(), - } + return migrated + }, + } + }) const { decodeStoredSession } = await configuredDecoder(1, [step]) const stored = storedSource(0, eventLog()) @@ -329,34 +350,59 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(events[0]?.data).toMatchObject({ migrationPath: [1] }) }) - it('passes the stable stored identity to every header and event step', async () => { - const contexts: SessionFormatContext[] = [] - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader(meta, context) { - contexts.push(context) - return { ...(meta as Record), version: 1 } - }, - migrateEvents(events, context) { - contexts.push(context) - return events - }, - } - const { decodeStoredSession } = await configuredDecoder(1, [step]) + it('retains instance state from the header through events and finishes at EOF', async () => { + const calls: string[] = [] + const Migration = defineMigration(0, () => { + let headerId: SessionId | undefined + let migratedEvents = 0 + return { + header(meta) { + calls.push('header') + headerId = SessionId((meta as Record)['id'] as string) + return { ...(meta as Record), version: 1 } + }, + event(value) { + calls.push(`event:${migratedEvents}`) + migratedEvents += 1 + return { + ...(value as SessionEvent), + data: { ...(value as SessionEvent).data, headerId, migratedEvents }, + } + }, + finish() { + calls.push(`finish:${migratedEvents}`) + }, + } + }) + const { decodeStoredSession } = await configuredDecoder(1, [Migration]) const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) - await collectEvents(decoded.events) + expect(calls).toEqual(['header']) + const events = await collectEvents(decoded.events) await decoded.completed - expect(contexts).toEqual([{ sessionId: id }, { sessionId: id }]) + expect(calls).toEqual(['header', 'event:0', 'event:1', 'finish:2']) + expect(events.map(event => event.data)).toMatchObject([ + { headerId: id, migratedEvents: 1 }, + { headerId: id, migratedEvents: 2 }, + ]) }) it('migrates and validates a header without requiring an event source', async () => { const calls: string[] = [] + const first = defineMigration(0, () => ({ + header(meta) { + calls.push('header:0') + return { ...(meta as Record), version: 1 } + }, + event: value => value, + finish() { + calls.push('finish:0') + }, + })) const { decodeStoredSessionHeader } = await configuredDecoder( 2, - [migration(0, calls), migration(1, calls)], + [first, migration(1, calls)], calls, ) @@ -366,18 +412,35 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) }) - it('applies event migration before the current event vocabulary check', async () => { - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => (async function* (): AsyncIterable { - for await (const value of events) { - const event = value as Record - yield { ...event, type: 'turn/start', data: { turn: 1 } } - } - })(), + it('allows a migration instance without finish', async () => { + class MigrationWithoutFinish implements SessionFormatMigrationInstance { + static readonly from = 0 + static readonly to = 1 + + header(meta: unknown): unknown { + return { ...(meta as Record), version: 1 } + } + + event(value: unknown): unknown { + return value + } } + const { decodeStoredSession } = await configuredDecoder(1, [MigrationWithoutFinish]) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + + await expect(collectEvents(decoded.events)).resolves.toEqual(eventLog()) + await expect(decoded.completed).resolves.toEqual({}) + }) + + it('applies event migration before the current event vocabulary check', async () => { + const step = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as Record + return { ...event, type: 'turn/start', data: { turn: 1 } } + }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const stored = storedSource(0, [ { type: 'legacy/turn-begin', seq: 0, time: 1, data: { legacyTurn: 1 } }, @@ -750,13 +813,11 @@ describe('versioned Session format decoder', { concurrent: false }, () => { await expect(decoded.completed).resolves.toEqual({}) }) - it('rejects a step that returns the wrong header version', async () => { - const bad: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 0 }), - migrateEvents: events => events, - } + it('rejects a migration that returns the wrong header version', async () => { + const bad = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 0 }), + event: value => value, + })) const first = await configuredDecoder(1, [bad]) expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) @@ -764,12 +825,10 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(first.validateHeader).not.toHaveBeenCalled() const calls: string[] = [] - const badSecond: SessionFormatStep = { - from: 1, - to: 2, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => events, - } + const badSecond = defineMigration(1, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event: value => value, + })) const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls) const stored = storedSource(0, []) expect(() => second.decodeStoredSession(stored.source, id)) @@ -779,23 +838,19 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(stored.reads).toEqual([]) }) - it('rejects a step that changes the session id or cwd storage identity', async () => { - const changedId: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1, id: 'other' }), - migrateEvents: events => events, - } + it('rejects a migration that changes the session id or cwd storage identity', async () => { + const changedId = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1, id: 'other' }), + event: value => value, + })) const first = await configuredDecoder(1, [changedId]) expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) .toThrow(/changed session storage identity/) - const changedCwd: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1, cwd: '/other' }), - migrateEvents: events => events, - } + const changedCwd = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1, cwd: '/other' }), + event: value => value, + })) const second = await configuredDecoder(1, [changedCwd]) const stored = storedSource(0, []) stored.meta['cwd'] = '/work' @@ -805,12 +860,10 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('wraps a header migration failure with the failing version step', async () => { const cause = new Error('bad legacy header') - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: () => { throw cause }, - migrateEvents: events => events, - } + const step = defineMigration(0, () => ({ + header: () => { throw cause }, + event: value => value, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) let failure: unknown @@ -827,14 +880,10 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('mirrors an event migration failure through the stream and completion promise', async () => { const cause = new Error('bad legacy event') - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: () => (async function* (): AsyncIterable { - throw cause - })(), - } + const step = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event: () => { throw cause }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) const completion = decoded.completed.catch((error: unknown) => error) @@ -843,23 +892,35 @@ describe('versioned Session format decoder', { concurrent: false }, () => { const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) expect(streamFailure).toBe(completionFailure) expect(streamFailure).toMatchObject({ - message: `session "${id}" event migration v0 -> v1 failed`, + message: `session "${id}" event migration v0 -> v1 failed at seq 0`, cause, }) }) - it('runs current event validation on the migrated output', async () => { - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => (async function* (): AsyncIterable { - for await (const value of events) { - const event = value as SessionEvent - yield { ...event, seq: event.seq + 1 } - } - })(), - } + it('mirrors a finish failure through the stream and completion promise', async () => { + const cause = new Error('unclosed legacy state') + const Migration = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event: value => value, + finish: () => { throw cause }, + })) + const { decodeStoredSession } = await configuredDecoder(1, [Migration]) + + const failure = await decodedFailure(decodeStoredSession(storedSource(0, eventLog()).source, id)) + expect(failure).toMatchObject({ + message: `session "${id}" event migration v0 -> v1 failed at EOF`, + cause, + }) + }) + + it('rejects a migration that changes an event sequence number', async () => { + const step = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as SessionEvent + return { ...event, seq: event.seq + 1 } + }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) const completion = decoded.completed.catch((error: unknown) => error) @@ -867,21 +928,30 @@ describe('versioned Session format decoder', { concurrent: false }, () => { const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) expect(streamFailure).toBe(completionFailure) - expect((streamFailure as Error).message).toMatch(/expected 0, got 1/) + expect((streamFailure as Error).message).toMatch(/changed event seq 0 to 1/) + }) + + it('rejects a non-contiguous current-format event sequence', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(0, [ + { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } }, + ]) + + const failure = await decodedFailure(decodeStoredSession(stored.source, id)) + + expect(failure.message).toContain(`session "${id}" event seq mismatch: expected 0, got 1`) }) it('runs current header validation only after the final header step', async () => { const calls: string[] = [] - const finalStep: SessionFormatStep = { - from: 1, - to: 2, - migrateHeader(meta) { + const finalStep = defineMigration(1, () => ({ + header(meta) { calls.push('header:1') const { createdAt: _createdAt, ...rest } = meta as Record return { ...rest, version: 2 } }, - migrateEvents: events => events, - } + event: value => value, + })) const { decodeStoredSession } = await configuredDecoder( 2, [migration(0, calls), finalStep], @@ -898,23 +968,19 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('detaches stored header and event objects before a mutating migration runs', async () => { const originalEvents = eventLog() const eventSnapshot = structuredClone(originalEvents) - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader(meta) { + const step = defineMigration(0, () => ({ + header(meta) { const record = meta as Record record['version'] = 1 return record }, - migrateEvents: events => (async function* (): AsyncIterable { - for await (const value of events) { - const event = value as SessionEvent - const data = event.data as Record - data['mutated'] = true - yield event - } - })(), - } + event(value) { + const event = value as SessionEvent + const data = event.data as Record + data['mutated'] = true + return event + }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const stored = storedSource(0, originalEvents) @@ -930,23 +996,16 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('rejects duplicate, invalid, and future-targeting static registries at initialization', async () => { const calls: string[] = [] await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)])) - .rejects.toThrow(/duplicate Session format step/) + .rejects.toThrow(/duplicate Session format migration/) await expect(configuredDecoder(1, [migration(-1, calls)])) .rejects.toThrow(/adjacent non-negative version/) - const nonAdjacent: SessionFormatStep = { - ...migration(0, calls), - to: 2, - } + const nonAdjacent = migration(0, calls, 2) await expect(configuredDecoder(2, [nonAdjacent])) .rejects.toThrow(/adjacent non-negative version/) - const fractional: SessionFormatStep = { - ...migration(0, calls), - from: 0.5, - to: 1.5, - } + const fractional = migration(0.5, calls, 1.5) await expect(configuredDecoder(2, [fractional])) .rejects.toThrow(/adjacent non-negative version/) From 7e3d5332dc305f06289ef103c3b34cc8ed77ac8a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 17:48:45 +0800 Subject: [PATCH 11/15] fix(session): address migration review feedback --- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 26 +++++++- docs/subsystems/persistence.zh.md | 26 +++++++- packages/core/session/src/types.ts | 6 +- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 16 +++-- .../tests/jsonl.spec.ts | 17 +++++ .../session-persistence/src/format-decoder.ts | 15 +++-- .../tests/format-decoder.spec.ts | 63 +++++++++++++++++++ scripts/type-equiv.manifest.json | 5 ++ 12 files changed, 162 insertions(+), 24 deletions(-) diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 886f483490..57476a2fb6 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 1480780b343e2d55544e441362abde58ffdffb2b -persistence.zh.md: 67bd8900fbcd0d006adb80da13fa8dbb2b6dd3e0 +persistence.md: c08ac3e37a34678b3731a251f727e1648e00211e +persistence.zh.md: 91d8b2549cb78f5fb4d00be63b4c7e787b910fad diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 1480780b34..c08ac3e37a 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -51,8 +51,8 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t interface SessionHeader { /** * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the - * session is created. A persistence backend rejects any other version on load - * (no migration — see the constant). + * session is created. Persistence refuses newer versions and older versions + * without a complete registered migration path. */ readonly version: number /** The session's id (mirrors the {@link Session}'s id). */ @@ -91,7 +91,27 @@ interface SessionHeader { ## Format refusal — logs a build cannot faithfully read -A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it requires a complete registered adjacent-version migration path or names the missing step. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale lives in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). + +## `SessionFormatMigration` — adjacent static format upgrades + +Each migration class declares one adjacent `from`/`to` pair and creates fresh state for one decode attempt. The decoder snapshots every header and event output as detached lossless JSON before the next migration receives it, preserves event sequence numbers, and calls optional EOF validation only after the complete event stream is consumed. The [package README](../../packages/session/session-persistence/README.md) owns the registration and version-bump procedure. + +```ts type-equiv +/** Static identity and constructor for one adjacent-version migration. */ +interface SessionFormatMigration { + /** Input Session format version. */ + readonly from: number + /** Output Session format version; must equal `from + 1`. */ + readonly to: number + /** + * Create fresh state for one header decode and its optional complete event + * stream. Instances are never shared across sessions or decode attempts. + * @returns a single-use migration instance. + */ + new(): SessionFormatMigrationInstance +} +``` ## `CreateSessionOptions` — seeding and metadata diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 67bd8900fb..91d8b2549c 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -51,8 +51,8 @@ interface SessionLocation { interface SessionHeader { /** * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the - * session is created. A persistence backend rejects any other version on load - * (no migration — see the constant). + * session is created. Persistence refuses newer versions and older versions + * without a complete registered migration path. */ readonly version: number /** The session's id (mirrors the {@link Session}'s id). */ @@ -91,7 +91,27 @@ interface SessionHeader { ## 格式拒绝:本构建无法可靠读取的日志 -后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。 +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时则要求一条完整注册的相邻版本迁移路径,否则会指出缺失步骤。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。 + +## `SessionFormatMigration`:相邻静态格式升级 + +每个迁移 class 声明一组相邻的 `from`/`to`,并为一次解码创建全新状态。decoder 会将每次 header 和事件输出快照为分离的无损 JSON,再交给下一项迁移,同时保留事件 seq;只有完整消费事件流后,才会调用可选的 EOF 验证。[包 README](../../packages/session/session-persistence/README.zh.md)负责说明注册与版本递增步骤。 + +```ts type-equiv +/** Static identity and constructor for one adjacent-version migration. */ +interface SessionFormatMigration { + /** Input Session format version. */ + readonly from: number + /** Output Session format version; must equal `from + 1`. */ + readonly to: number + /** + * Create fresh state for one header decode and its optional complete event + * stream. Instances are never shared across sessions or decode attempts. + * @returns a single-use migration instance. + */ + new(): SessionFormatMigrationInstance +} +``` ## `CreateSessionOptions`:seed 与元数据 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 31ce28a01b..3c12f5ee07 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -35,7 +35,7 @@ export function SessionId(id: string): SessionId { * and enforced by every persistence backend on load. The single source of truth for the * version — write sites and the load-time check all read it. * While the harness is unreleased it is pinned at `0`: no compatibility is - * implied, incompatible logs are rejected, and no migration is provided. + * implied; older logs load only through a complete adjacent migration path. * * The version is a single monotonic integer with no major/minor split. Whether * a bump is needed is decided by what the WRITER emits, never by what a newer @@ -61,8 +61,8 @@ export const SESSION_FORMAT_VERSION = 0 export interface SessionHeader { /** * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the - * session is created. A persistence backend rejects any other version on load - * (no migration — see the constant). + * session is created. Persistence refuses newer versions and older versions + * without a complete registered migration path. */ readonly version: number /** The session's id (mirrors the {@link Session}'s id). */ diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index b4401a1dc3..3501fa53df 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 00f893c134133207a1e9a12397c996c7c6c0c76c -README.zh.md: 800eaf6a8d758a18ac3bbecb87644b4f95838119 +README.md: 73e503d8f6741e84ee50be8cc4cbfbf2c07babc8 +README.zh.md: 9e778cb155f9f1d964bc9592057ae39bb0afb8cb diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 00f893c134..73e503d8f6 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -35,7 +35,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation. -A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. Session format steps can replace a logical log within its configured encoding; there is no compression migration, mixed-root fallback, or dual write. +A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. Session format migrations can replace a logical log within its configured encoding; there is no compression migration, mixed-root fallback, or dual write. ## Durability and crash semantics diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 800eaf6a8d..9e778cb155 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -35,7 +35,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d 默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。 -一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。Session 格式步骤可以在已配置编码内替换逻辑日志;不提供压缩迁移、混合根回退或双写。 +一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。Session 格式迁移可以在已配置编码内替换逻辑日志;不提供压缩迁移、混合根回退或双写。 ## 持久性与崩溃语义 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index d4d8e5c19f..a6d222cf5c 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -39,9 +39,9 @@ export type { JsonlCompression } from './format.ts' const DEFAULT_PACK_CHUNKS = true const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' /** - * Internal scheduling constant, not deployment configuration: balance - * frame-boundary event-loop yields against `setImmediate` overhead. One frame - * remains an indivisible synchronous decode. + * Internal scheduling constants, not deployment configuration: decode yields + * balance frame latency against `setImmediate` overhead; replacement batches + * bound memory and frame granularity without changing durable behavior. */ const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 const REPLACEMENT_BATCH_SIZE = 128 @@ -605,8 +605,14 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi if (first === undefined) continue // empty/half-written file const rawMeta = parseStoredHeaderMeta(first) if (rawMeta === undefined) continue // not a session header - const identity = this.storedIdentity(rawMeta, path) - const meta = decodeStoredSessionHeader(rawMeta, identity.id, { kind: 'jsonl', path }) + const rawId = typeof rawMeta === 'object' && rawMeta !== null + ? (rawMeta as Record)['id'] + : undefined + const expectedId = typeof rawId === 'string' + ? SessionId(rawId) + : SessionId('') + const meta = decodeStoredSessionHeader(rawMeta, expectedId, { kind: 'jsonl', path }) + this.storedIdentity(rawMeta, path) await this.assertStoredIdentity(path, rawMeta, undefined, signal) signal?.throwIfAborted() if (ids.has(meta.id)) { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index ff88640e34..2a10b14f2f 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1524,6 +1524,23 @@ describe('JsonlSessionPersistence: edge cases', () => { expect(await ctx.sessionPersistence.list()).toEqual([]) }) + it('listing refuses a future format before validating current identity fields', async () => { + const id = SessionId('future-list') + const path = rawLogPath(root, '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`) + + for (const list of [ + () => ctx.sessionPersistence.list(), + () => ctx.sessionPersistence.listSnapshots(), + ]) { + const failure = await list().then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('session "123" uses log format v42') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + } + }) + it('keeps the transcript in an extensible session-owned directory', async () => { const m = meta('owned-directory', '/project') await ctx.sessionPersistence.create(m) diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index 19eb383a03..398b487860 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -33,8 +33,9 @@ interface SessionFormatMigrationInstance { */ header(meta: unknown): unknown /** - * Transform exactly one event while retaining its sequence number. Instance - * fields may accumulate facts from the header and earlier events. + * Transform exactly one event into detached lossless JSON while retaining + * its sequence number. Instance fields may accumulate facts from the header + * and earlier events. * @param event - detached input event in durable sequence order. * @returns exactly one detached event for the same sequence number. */ @@ -137,7 +138,10 @@ export interface DecodedSession { readonly revision: SessionPersistenceRevision /** Validated current-format events at or past the requested sequence. */ readonly events: AsyncIterable - /** Completion metadata from the physical read supplying the events. */ + /** + * Completion metadata from the physical read supplying the events. Settles + * only after the events iterable is fully consumed or fails. + */ readonly completed: Promise> } @@ -363,7 +367,10 @@ async function* transformEvents( const sourceSeq = asStoredRecord(value)?.['seq'] let output: unknown try { - output = instance.event(value) + output = snapshotJsonValue(instance.event(value)) + if (output === undefined) { + throw new Error('migration returned an event that is not losslessly JSON-serializable') + } } catch (error: unknown) { throw new Error( `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at seq ${String(sourceSeq)}`, diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 5ba23fab17..5b80ac15c6 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -316,6 +316,69 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] }) }) + it('detaches each migration output before the next migration mutates its input', async () => { + const retained: Array> = [] + const first = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as SessionEvent + const output = { + ...event, + data: { ...(event.data as Record), first: true }, + } + retained.push(output.data) + return output + }, + })) + const second = defineMigration(1, () => ({ + header: meta => ({ ...(meta as Record), version: 2 }), + event(value) { + const event = value as SessionEvent + const data = event.data as Record + data['second'] = true + return event + }, + })) + const { decodeStoredSession } = await configuredDecoder(2, [first, second]) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(events.every(event => (event.data as Record)['second'] === true)).toBe(true) + expect(retained.every(data => data['second'] === undefined)).toBe(true) + }) + + it('rejects a non-JSON event output before a later migration can repair it', async () => { + const first = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as SessionEvent + return { + ...event, + data: { ...(event.data as Record), transient: undefined }, + } + }, + })) + const second = defineMigration(1, () => ({ + header: meta => ({ ...(meta as Record), version: 2 }), + event(value) { + const event = value as SessionEvent + const data = event.data as Record + delete data['transient'] + return event + }, + })) + const { decodeStoredSession } = await configuredDecoder(2, [first, second]) + + const failure = await decodedFailure( + decodeStoredSession(storedSource(0, eventLog()).source, id), + ) + + expect(failure.message).toMatch(/event migration v0 -> v1 failed at seq 0/) + expect((failure.cause as Error).message).toMatch(/not losslessly JSON-serializable/) + }) + it('plans by version even when registry entries are declared out of order', async () => { const calls: string[] = [] const { decodeStoredSession } = await configuredDecoder( diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8b580750bd..2b29010478 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -545,6 +545,11 @@ "symbol": "SessionLocation", "source": "packages/session/session-persistence/src/index.ts" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionFormatMigration", + "source": "packages/session/session-persistence/src/format-decoder.ts" + }, { "doc": "docs/subsystems/persistence.md", "symbol": "SessionRawArtifact", From 95ed302c9caf735006cde07f97c93541b67d62f4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 22:08:53 +0800 Subject: [PATCH 12/15] fix(session): remove unreachable listing branch --- packages/session/session-persistence-jsonl/src/format.ts | 2 +- packages/session/session-persistence-jsonl/src/index.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index bf6d1fde2d..321278088a 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -397,7 +397,7 @@ export function scanLog(buffer: Buffer): SessionLogScan { * @param firstLine - first JSONL record without its newline. * @returns normalized logical header JSON, or `undefined` for invalid framing. */ -export function parseStoredHeaderMeta(firstLine: string): unknown { +export function parseStoredHeaderMeta(firstLine: string): Record | undefined { let parsed: unknown try { parsed = JSON.parse(firstLine) diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index a6d222cf5c..d80979da2e 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -605,9 +605,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi if (first === undefined) continue // empty/half-written file const rawMeta = parseStoredHeaderMeta(first) if (rawMeta === undefined) continue // not a session header - const rawId = typeof rawMeta === 'object' && rawMeta !== null - ? (rawMeta as Record)['id'] - : undefined + const rawId = rawMeta['id'] const expectedId = typeof rawId === 'string' ? SessionId(rawId) : SessionId('') From 3d660d2db2cb68667b1a1804ed6865e947547d6e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 22 Aug 2026 16:26:31 +0800 Subject: [PATCH 13/15] ci: raise host TypeScript heap budget --- package.json | 2 +- scripts/wine-windows-gates.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 391c93d938..86f3e40a24 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build": "tsx scripts/build.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "npm run build:lib:host && npm run build:lib:client", - "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "clean": "tsx scripts/clean.ts", diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 5e46b75f91..f9f04faea0 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -243,7 +243,7 @@ grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-ga # Host face before compiling and bundling the Client face. # Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false || return $? wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client From 65295d5b6854e7da0434919ee3be02807de79f30 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 22 Aug 2026 21:51:49 +0800 Subject: [PATCH 14/15] docs(session): refresh persistence pairing record --- packages/session/session-persistence/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index a7d636a65b..1605bdbee5 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 4a3111f8e3d38add9204b121dd100c9fa5a78d7c -README.zh.md: 5d93f952224b31b233986e7e4a90d319edde4e19 +README.md: 323d7b23cff6438264ae4aa4a3fecbd06a832037 +README.zh.md: bb667f6989f1a0df9d258d223f0f6721a233433a From 1825cb4657061ffe70d9ebd11c3e3575329fc21b Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 25 Aug 2026 12:42:08 +0800 Subject: [PATCH 15/15] feat(client): highlight streaming fences incrementally Keep recognized code fences syntax-highlighted while assistant text streams. Preserve completed Shiki token lines across chunks, mirror token styles and CRLF handling, and retain plain rendering for unsupported or math-like fences. Add unit, DOM-parity, and keyless assembled-Web coverage for the streaming-to-settled transition. Closes #1499 --- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 2 +- .../2026-07-23-web-assistant-markdown.zh.md | 2 +- ...20-web-streaming-fence-highlight.i18n.yaml | 6 + ...026-08-20-web-streaming-fence-highlight.md | 37 +++ ...-08-20-web-streaming-fence-highlight.zh.md | 37 +++ .../mid-stream.expected.md | 97 ++++++++ .../tests/streaming-fence-highlight.e2e.ts | 146 +++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 88 ++++++- .../src/markdown/MarkdownText.tsx | 7 +- .../ui-primitives/src/markdown/highlight.ts | 147 ++++++++++- .../ui-primitives/src/markdown/render.tsx | 10 +- .../markdown-dom/code-fences.streaming.txt | 31 ++- .../fence-trailing-blank-lines.streaming.txt | 17 +- .../math-edge-cases.streaming.txt | 1 + .../tests/markdown-dom-parity.client.spec.tsx | 4 + .../tests/markdown.client.spec.tsx | 42 +++- .../streaming-code-block.client.spec.tsx | 229 ++++++++++++++++++ tsconfig.host.json | 1 + 23 files changed, 877 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md create mode 100644 apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md create mode 100644 apps/web/tests/streaming-fence-highlight.e2e.ts create mode 100644 packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 303caf020b..671ca8f796 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: 0ad74546b9a54d5eadfc3e7991efa4e0d9b7bc77 -2026-07-23-web-assistant-markdown.zh.md: 9349847228b10c13ecead4374ad2fc9210f5e9f1 +2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b +2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 0ad74546b9..d2b8e30d77 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -12,7 +12,7 @@ The Web conversation preserves assistant Markdown source through session events, `@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. -`MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. +`MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism). Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through the settled grammar's math extensions; `mathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 9349847228..c1542d75fa 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -12,7 +12,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 -`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 +`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。 视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX;`mathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml new file mode 100644 index 0000000000..2139112dec --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md +2026-08-20-web-streaming-fence-highlight.md: ccc961da1febba3611e3e558087b586c6f1f474b +2026-08-20-web-streaming-fence-highlight.zh.md: e5d29545bf659ed572d052f1212d68d344d49b5a diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md new file mode 100644 index 0000000000..ccc961da1f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md @@ -0,0 +1,37 @@ +# Agent Note: Streaming fences highlight incrementally + +Status: implemented + +English | [中文](2026-08-20-web-streaming-fence-highlight.zh.md) + +## Problem + +While a reply streamed, `MarkdownText` stripped the fence language before `CodeBlock` saw it, so code rendered as plain monospace with an empty language banner until the finalize swap recolored the whole reply at once ([#1499](https://github.com/deepseek-harness/deepseek-harness/issues/1499)). The plain arm was a deliberate cost guard, recorded in the [assistant-markdown note](2026-07-23-web-assistant-markdown.md): shiki tokenizes a document from the top, so highlighting a growing fence naively re-tokenizes the whole fence on every chunk — quadratic in fence length over the stream, the same cost class the [incremental markdown parser](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) removes for block parsing. The fix has to deliver highlighting during streaming without reintroducing that cost, without transiently coloring under a wrong grammar while the info string is still mid-chunk, and without changing the settled render. + +## Decision + +Streaming fences highlight incrementally through grammar-state resumption; the settled arm is unchanged. + +- **`StreamingHighlightSession`** (`packages/client/ui-primitives/src/markdown/highlight.ts`) exploits that TextMate tokenization is line-based and forward-only: a line's tokens depend only on its own text and the grammar state entering it, so appended text never changes a completed line's tokens. The session caches completed lines' spans plus shiki's `GrammarState` after them (`getLastGrammarState`), and each update tokenizes newly completed text via `codeToTokensBase(…, { grammarState })` plus the still-growing last line. Per-chunk cost excludes the completed prefix; the result is token-identical to a from-scratch tokenization. Non-append input and a resolved-grammar change reset the cache and re-tokenize fully. Each run carries the style shiki's HTML arm would assign it — the css-variables color plus the markup font-style bits the theme lets through (bold/italic/underline; markdown fences carry them); whitespace-only runs fold into their following token as shiki's default `mergeWhitespaces` does (its underlined/struck-whitespace exemption cannot occur under this theme, whose only underline rule styles inline-link scopes that tokenize spaced text as one run); and a CRLF cut never leaks its `\r` into the last completed line, matching shiki's own line splitting — so the streaming spans and the settled `codeToHtml` swap render one identical span tree. +- **`CodeBlock`** gains a `streaming` prop: it renders the session's spans as a `pre.shiki.css-variables` React tree with the same attributes shiki's HTML emits, holds the session and per-line elements in refs, and reuses a retained line's element identity so React leaves that line's DOM untouched. Unknown or absent languages keep the identical-geometry plain arm; a lazy grammar renders plain until it registers, then the existing `useSyncExternalStore` load signal re-renders into highlight — one plain→highlighted transition, no flicker back. +- **`render.tsx`** passes `lang` and `context.streaming` to fences. Wrong-grammar transients are structurally impossible: a fence whose info string is still mid-chunk (`` ```py `` completing to `` ```python ``) has no content yet — content only exists after the info line's newline, which finalizes the language — and the empty-value fence keeps the stock `
`. The streaming CodeBlock instance survives every chunk because streaming render keys are source offsets. `` ```math `` fences and TeX stay literal until the settled pass; the language banner shows the fence language during streaming.
+
+The settle swap re-renders through `highlightToHtml`: same tokens, same span tree, so the swap is visually invisible and never touches the code content.
+
+## Testing
+
+Package tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; cache identity and reset/lazy paths; streaming/settled token-tree parity; DOM retention; and plain or math fallbacks. The assembled Web browser snapshot boots the real Web composition, streams a TypeScript fence through the Host and SSE path, pauses the deterministic LLM adapter while the reply is still active, and snapshots Chromium's Shiki token tree before verifying that settlement preserves it. The `tests/fixtures/markdown-dom/*.streaming.txt` fixtures pin the intentional streaming divergence from their react-markdown origin: the Shiki span tree and visible language banner replace the plain arm.
+
+## Alternatives considered
+
+**Pass `lang` through and re-tokenize the whole fence per chunk.** One-line fix, but it reverses the recorded plain-arm rationale without addressing it: a long streaming fence pays quadratic tokenization over the stream, janking exactly on the replies where highlighting matters most.
+
+**Highlight only frozen (closed, settled-position) fences during streaming.** Bounded cost, but an unclosed fence pins the incremental parser's tail, so the actively growing fence — the one on screen — would stay plain until the reply finishes, failing the issue's "识别语言后即可增量高亮".
+
+**Move highlighting to a worker or async pass.** Rejected when shiki was adopted ([synchronous highlighting note](../process/2026-07-26-web-syntax-highlighting-shiki.md)); an async swap also reintroduces the plain→colored→plain flicker class this change must avoid.
+
+**Build the settled HTML string incrementally and keep `dangerouslySetInnerHTML`.** Exact settled parity for free, but React replaces the whole `innerHTML` per chunk, so the browser re-parses and rebuilds every line's DOM each time — O(fence) DOM churn that forfeits the token-level win the session provides.
+
+## Consequences
+
+Streaming code is readable as it arrives: tokens color as soon as the language is known, completed lines never re-tokenize or re-render, and the finalize swap is invisible for fences. The package owns a small mirror of shiki's HTML-arm conventions — the `pre` attributes and the whitespace fold — pinned by the arm-parity test, so a shiki upgrade that changes either fails loud there instead of drifting the two arms apart. The streaming DOM-parity fixtures pin Shiki span trees as an intentional divergence from their react-markdown origin. The still-growing last line re-tokenizes per chunk (bounded by one line), and a pathological single-line fence still degrades to full re-tokenization per chunk — the same degradation class the incremental block parser accepts for a single giant block.
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
new file mode 100644
index 0000000000..e5d29545bf
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: 流式围栏代码增量高亮
+
+Status: implemented
+
+[English](2026-08-20-web-streaming-fence-highlight.md) | 中文
+
+## Problem
+
+回复流式输出期间,`MarkdownText` 在 `CodeBlock` 看到围栏语言之前就把它剥掉,代码因此以无高亮的等宽纯文本呈现、语言横幅为空,直到定稿切换一次性重新着色整个回复([#1499](https://github.com/deepseek-harness/deepseek-harness/issues/1499))。纯文本臂是一道刻意的成本防线,记录于 [assistant-markdown 笔记](2026-07-23-web-assistant-markdown.zh.md):shiki 从文档顶部开始 tokenize,朴素地高亮一个增长中的围栏意味着每个分片都重新 tokenize 整个围栏——随流式过程对围栏长度呈平方级,与[增量 markdown 解析器](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)为块解析消除的是同一类成本。修复必须在流式期间给出高亮,同时不重新引入该成本、不在 info string 尚在分片中途时以错误语法短暂着色、也不改变定稿渲染。
+
+## Decision
+
+流式围栏通过 grammar state 续接实现增量高亮;定稿臂保持不变。
+
+- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`),每次更新通过 `codeToTokensBase(…, { grammarState })` tokenize 新完成的文本,外加仍在增长的最后一行。每分片成本不包含已完成的前缀;结果与从头 tokenize 逐 token 一致。非追加输入与解析后语法变化会重置缓存并完整重新 tokenize。每个 run 携带 shiki HTML 臂会赋予它的样式——css-variables 颜色加上主题放行的 markup 字体位(bold/italic/underline;markdown 围栏会携带它们);纯空白 run 并入其后的 token,与 shiki 默认的 `mergeWhitespaces` 一致(其对带下划线/删除线空白的豁免在该主题下不可能出现:主题唯一的 underline 规则作用于 inline-link scope,其含空格文本整体成一个 run);CRLF 切割点的 `\r` 绝不进入最后一个已完成行,与 shiki 自身的行切分一致——因此流式 span 与定稿 `codeToHtml` 换入的 span 树完全一致。
+- **`CodeBlock`** 新增 `streaming` prop:把会话的 span 渲染为带有 shiki HTML 同款属性的 `pre.shiki.css-variables` React 树,用 ref 持有会话与逐行元素,并复用保留行的元素标识,让 React 完全不触碰该行的 DOM。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。
+- **`render.tsx`** 向围栏传递 `lang` 与 `context.streaming`。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 `
`。流式渲染 key 是源偏移,围栏的 CodeBlock 实例因此跨分片存活。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
+
+定稿切换经 `highlightToHtml` 重渲染:token 相同、span 树相同,切换在视觉上不可见,也绝不触碰代码内容。
+
+## Testing
+
+包测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,缓存标识与重置/懒加载路径,流式/定稿 token 树一致性,DOM 保留,以及纯文本和 math 回退。组装后的 Web 浏览器快照会启动真实 Web 组合,让 TypeScript 围栏经过 Host 与 SSE 路径流式传输,在回复仍活跃时暂停确定性 LLM 适配器并对 Chromium 中的 Shiki token 树做快照,然后验证定稿保留该 token 树。`tests/fixtures/markdown-dom/*.streaming.txt` fixture 锁定相对 react-markdown 来源的一项有意分叉:Shiki span 树与可见语言横幅取代纯文本臂。
+
+## Alternatives considered
+
+**直接透传 `lang`,每个分片重新 tokenize 整个围栏。** 一行改动,但在不回应的情况下推翻了已记录的纯文本臂理由:长流式围栏在整个流式过程付出平方级 tokenize 成本,恰恰在高亮最有价值的长代码回复上产生卡顿。
+
+**流式期间只高亮已冻结(闭合且位置定格)的围栏。** 成本有界,但未闭合围栏会钉住增量解析器的尾部,于是正在增长的围栏——屏幕上的那个——要等回复结束才高亮,不满足 issue 的"识别语言后即可增量高亮"。
+
+**把高亮移到 worker 或异步流程。** 采纳 shiki 时已否决([同步高亮笔记](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md));异步换入还会重新引入本变更必须避免的纯文本→彩色→纯文本闪烁类问题。
+
+**增量拼接定稿 HTML 字符串并继续使用 `dangerouslySetInnerHTML`。** 白得定稿一致性,但 React 每个分片都会整体替换 `innerHTML`,浏览器每次重新解析并重建所有行的 DOM——O(围栏) 的 DOM 翻搅,抵消了会话在 token 层的收益。
+
+## Consequences
+
+流式代码随到达即可读:语言一经识别 token 即着色,已完成行绝不重新 tokenize 或重渲染,定稿切换对围栏而言不可见。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。仍在增长的最后一行每分片重新 tokenize(以一行为界);病态的单行超长围栏仍退化为每分片完整重新 tokenize——与增量块解析器对单个巨型块接受的是同一退化类。
diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
new file mode 100644
index 0000000000..7207de464b
--- /dev/null
+++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
@@ -0,0 +1,97 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Stream one TypeScript fence for" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
+- button "Copy":
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- text: ts
+- button "Copy"
+- code: "const first: number = 1 const second = \"two\" let tail"
+- status: Deep diving...
+- textbox "Message the agent"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current streaming-fence-highlight-test/streaming-fence":
+  - text: streaming-fence-highlight-test/streaming-fence
+  - img
+- button "Stop generating"
+
+---
+
+{
+  "language": "ts",
+  "pre": {
+    "className": "shiki css-variables",
+    "style": "background-color: var(--shiki-background); color: var(--shiki-foreground);",
+    "tabIndex": "0"
+  },
+  "lines": [
+    [
+      {
+        "text": "const",
+        "style": "color: var(--shiki-token-keyword);"
+      },
+      {
+        "text": " first",
+        "style": "color: var(--shiki-token-constant);"
+      },
+      {
+        "text": ":",
+        "style": "color: var(--shiki-token-keyword);"
+      },
+      {
+        "text": " number",
+        "style": "color: var(--shiki-token-constant);"
+      },
+      {
+        "text": " =",
+        "style": "color: var(--shiki-token-keyword);"
+      },
+      {
+        "text": " 1",
+        "style": "color: var(--shiki-token-constant);"
+      }
+    ],
+    [
+      {
+        "text": "const",
+        "style": "color: var(--shiki-token-keyword);"
+      },
+      {
+        "text": " second",
+        "style": "color: var(--shiki-token-constant);"
+      },
+      {
+        "text": " =",
+        "style": "color: var(--shiki-token-keyword);"
+      },
+      {
+        "text": " \"two\"",
+        "style": "color: var(--shiki-token-string-expression);"
+      }
+    ],
+    [
+      {
+        "text": "let",
+        "style": "color: var(--shiki-token-keyword);"
+      },
+      {
+        "text": " tail",
+        "style": "color: var(--shiki-foreground);"
+      }
+    ]
+  ]
+}
diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts
new file mode 100644
index 0000000000..594d51c949
--- /dev/null
+++ b/apps/web/tests/streaming-fence-highlight.e2e.ts
@@ -0,0 +1,146 @@
+/** Keyless assembled-Web evidence for syntax highlighting during a streamed code fence. */
+
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-agent-default-model'
+import {
+  assertFixtureInventory,
+  captureStableAria,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/streaming-fence-highlight', import.meta.url))
+const MID_EXPECTED = fileURLToPath(new URL('./snapshots/streaming-fence-highlight/mid-stream.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+const PROVIDER = 'streaming-fence-highlight-test'
+const MODEL = 'streaming-fence'
+const PROMPT = 'Stream one TypeScript fence for the highlighting snapshot.'
+const OPEN_REPLY = '```ts\nconst first: number = 1\nconst second = "two"\nlet tail'
+const REPLY = `${OPEN_REPLY}\n\`\`\``
+
+/** Deterministic model response held after the visible fence body arrives. */
+class StreamingFenceAdapter extends LlmAdapter {
+  private resolvePaused!: () => void
+  private resolveContinuation!: () => void
+  private continued = false
+  readonly paused = new Promise((resolve) => { this.resolvePaused = resolve })
+  private readonly continuation = new Promise((resolve) => { this.resolveContinuation = resolve })
+
+  continue(): void {
+    if (this.continued) return
+    this.continued = true
+    this.resolveContinuation()
+  }
+
+  override async *stream(options: GenerateOptions): AsyncIterable {
+    yield { type: 'block-start', index: 0, blockType: 'text' }
+    yield { type: 'text-delta', index: 0, text: OPEN_REPLY }
+    this.resolvePaused()
+    await this.continuation
+    if (options.signal?.aborted === true) throw options.signal.reason
+    yield { type: 'text-delta', index: 0, text: '\n```' }
+    yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
+    yield { type: 'finish', reason: { kind: 'stop' } }
+  }
+}
+
+interface FenceTree {
+  language: string
+  pre: { className: string; style: string | null; tabIndex: string | null }
+  lines: { text: string; style: string | null }[][]
+}
+
+/** Read the stable, user-visible subset of one rendered code fence. */
+async function fenceTree(block: ReturnType): Promise {
+  return await block.evaluate((element) => {
+    const pre = element.querySelector('pre.shiki')
+    if (pre === null) throw new Error('streaming fence did not render through the shiki arm')
+    return {
+      language: element.querySelector('[class*="infostring"]')?.textContent ?? '',
+      pre: {
+        className: pre.className,
+        style: pre.style.cssText,
+        tabIndex: pre.getAttribute('tabindex'),
+      },
+      lines: [...pre.querySelectorAll('.line')].map(line =>
+        [...line.querySelectorAll('span')].map(span => ({
+          text: span.textContent ?? '',
+          style: span.style.cssText,
+        })),
+      ),
+    }
+  })
+}
+
+describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType
+  const adapter = new StreamingFenceAdapter()
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold()
+    scaffold.ctx.effect(
+      () => scaffold.ctx.llm.registerAdapter([PROVIDER], adapter),
+      'streaming fence highlight adapter',
+    )
+    await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    adapter.continue()
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight'))
+    const input = page.locator('textarea').first()
+    const settled = scaffold.whenTurnSettled(30_000)
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    await adapter.paused
+
+    const streaming = page.locator('[data-streaming="true"]')
+    await streaming.waitFor({ timeout: 10_000 })
+    const block = streaming.locator('.md-code-block').filter({ hasText: 'const first' })
+    await block.locator('pre.shiki span[style]').first().waitFor({ timeout: 10_000 })
+    const midTree = await fenceTree(block)
+    expect(midTree.language).toBe('ts')
+    expect(midTree.lines).toHaveLength(3)
+    expect(midTree.lines.flat().map(span => span.style)).toContain('color: var(--shiki-token-keyword);')
+
+    const aria = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(
+      MID_EXPECTED,
+      `${aria}\n\n---\n\n${JSON.stringify(midTree, null, 2)}`,
+      MODE,
+    )
+
+    adapter.continue()
+    await settled
+    await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
+    const settledBlock = page.locator('.md-code-block').filter({ hasText: 'const first' })
+    await settledBlock.locator('pre.shiki').waitFor({ timeout: 10_000 })
+    expect(await fenceTree(settledBlock)).toEqual(midTree)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
+  }, 60_000)
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 386615f059..c14855169f 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,6 +43,7 @@
     "tests/models-settings.e2e.ts",
     "tests/default-model.e2e.ts",
     "tests/github-ready-review.e2e.ts",
+    "tests/streaming-fence-highlight.e2e.ts",
     "tests/declared-reasoning.e2e.ts",
     "tests/onboarding-deepseek-config.e2e.ts",
     "tests/onboarding-usable-provider.e2e.ts",
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 720136b270..e800ed34d7 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: c1c40e39710d46fae240f0b3281c36d660855010
-README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36
+README.md: 3b7b37cf59aafc7292f75e9e4fb1513b93b32c1c
+README.zh.md: aee1a2ac842b34b284a0be5589b6dd801498e76b
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index c1c40e3971..3b7b37cf59 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
 
 ## Markdown rendering
 
-`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
+`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars); while a reply streams, a fence highlights incrementally as it grows — each chunk tokenizes newly completed text from the saved grammar state plus the still-growing last line, while completed lines keep their DOM — and the settled render swaps in shiki's HTML with an identical span tree ([decision record](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
 
 ## Terminal output
 
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 631936ad65..aee1a2ac84 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -14,7 +14,7 @@
 
 ## Markdown 渲染
 
-`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
+`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki);回复流式输出期间,围栏随内容增长而增量高亮——每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,已完成的行保持其 DOM 不变——定稿渲染换入的 shiki HTML 具有完全一致的 span 树([决策记录](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
 
 ## 终端输出
 
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index cd109874cb..ba9cd43392 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -1,7 +1,11 @@
-import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import type { ReactNode } from 'react'
 import clsx from 'clsx'
 import { writeClipboard } from '../clipboard.ts'
-import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
+import {
+  StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
+} from './highlight.ts'
+import type { HighlightSpan } from './highlight.ts'
 import css from './CodeBlock.module.css'
 
 export interface CodeBlockProps {
@@ -9,6 +13,14 @@ export interface CodeBlockProps {
   code: string
   /** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
   lang?: string | undefined
+  /**
+   * The code is still growing (a streaming markdown fence): highlight through
+   * a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
+   * appended text and keeps completed lines' elements (and DOM) untouched.
+   * The caller must keep the component instance stable across growth (a
+   * stream-stable React key); settled callers omit this and get shiki's HTML.
+   */
+  streaming?: boolean | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
@@ -17,13 +29,61 @@ export interface CodeBlockProps {
   copiedLabel: string
 }
 
-export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: CodeBlockProps) {
+/**
+ * The `pre` attributes shiki's HTML arm emits for the css-variables theme,
+ * mirrored so the streaming arm's tree is interchangeable with the settled
+ * swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
+ * parity).
+ */
+const SHIKI_PRE_PROPS = {
+  className: 'shiki css-variables',
+  style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
+  tabIndex: 0,
+} as const
+
+export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   // Re-render when a lazy grammar finishes loading, so a fence that showed plain
   // text while its language's grammar imported picks up highlighting. The
   // snapshot value is opaque; only its change across renders drives the memo.
   const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
-  const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
+  const html = useMemo(
+    () => (streaming === true ? undefined : highlightToHtml(trimmed, lang)),
+    [streaming, trimmed, lang, loaded],
+  )
+  // Streaming state lives in refs mutated inside the memo (the MarkdownText
+  // streaming-cache pattern): the session's caches carry across chunks only
+  // because the owner keys this instance stably while the fence grows.
+  const sessionRef = useRef(null)
+  const lineCacheRef = useRef<{ lines: readonly HighlightSpan[][]; elements: ReactNode[] } | null>(null)
+  const streamedBody = useMemo(() => {
+    if (streaming !== true) {
+      sessionRef.current = null
+      lineCacheRef.current = null
+      return undefined
+    }
+    sessionRef.current ??= new StreamingHighlightSession()
+    const lines = sessionRef.current.update(trimmed, lang)
+    if (lines === undefined) {
+      lineCacheRef.current = null
+      return undefined
+    }
+    // A retained line keeps its span-array identity across chunks, so its
+    // cached element is reused and React leaves that line's DOM untouched.
+    const previous = lineCacheRef.current
+    const elements = lines.map((line, index) => previous !== null && previous.lines[index] === line
+      ? previous.elements[index]
+      : (
+        
+          {index > 0 && '\n'}
+          
+            {line.map((span, spanIndex) => {span.text})}
+          
+        
+      ))
+    lineCacheRef.current = { lines, elements }
+    return 
{elements}
+ }, [streaming, trimmed, lang, loaded]) const rootRef = useRef(null) const [copied, setCopied] = useState(false) @@ -39,16 +99,18 @@ export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: Cod }) }, [copied, trimmed]) - const body = html === undefined - ? ( -
{trimmed}
- ) - : ( - // shiki's output is a static span tree it generated from `code` (no user - // HTML passes through), the sanctioned innerHTML consumption path per + // shiki's HTML output is a static span tree it generated from `code` (no + // user HTML passes through), the sanctioned innerHTML consumption path per // shiki's own docs. -
- ) + const body = streamedBody !== undefined + ? streamedBody + : html === undefined + ? ( +
{trimmed}
+ ) + : ( +
+ ) return (
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index b4fc2d4678..3a26bac424 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -140,9 +140,10 @@ class StreamingRenderer { /** * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; - * `streaming` renders fences and TeX plain (highlighting and KaTeX land on - * the finalize swap) and parses incrementally across chunks; `labels` - * forwards localized fence and footnote chrome — pass a + * `streaming` parses incrementally across chunks and highlights fences as + * they grow (each fence re-tokenizes only appended text; TeX stays literal + * until the finalize swap so incomplete formulae never flash errors); + * `labels` forwards localized fence and footnote chrome — pass a * reference-stable object (memoized per locale revision), because a new * identity discards the streaming render cache mid-message. `fileMentions` * links inline-code tokens its resolver recognizes as real files; this is diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 16fb544dc0..047f9d1139 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -23,7 +23,7 @@ import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from ' import langTs from '@shikijs/langs/typescript' import langBash from '@shikijs/langs/shellscript' import langJson from '@shikijs/langs/json' -import type { HighlighterCore } from 'shiki/core' +import type { GrammarState, HighlighterCore, ThemedToken } from 'shiki/core' import type { CSSProperties } from 'react' /** A shiki grammar module's default export (a `LanguageRegistration[]`), taken @@ -278,6 +278,146 @@ export interface HighlightSpan { style: CSSProperties } +/** vscode-textmate FontStyle bits shiki folds into `text-decoration` values. */ +const DECORATION_BITS: readonly (readonly [number, string])[] = [[4, 'underline'], [8, 'line-through']] + +/** + * The inline style shiki's HTML arm assigns one token (`getTokenStyleObject` + * mirrored onto React style keys): the css-variables color plus the + * vscode-textmate font-style bits the theme lets through — italic (1), bold + * (2), and the {@link DECORATION_BITS} decorations (the theme injects bold, + * italic, and underline rules for markup scopes, so markdown fences carry + * them). The theme has no per-scope backgrounds, so `background-color` never + * occurs; the arm-parity tests fail loud if a shiki upgrade changes that. + */ +function spanStyle(token: ThemedToken): CSSProperties { + const style: CSSProperties = { color: token.color } + /* v8 ignore next -- fontStyle is optional in ThemedToken's type; tokenizeWithTheme always stamps it. */ + const bits = token.fontStyle ?? 0 + if ((bits & 1) !== 0) style.fontStyle = 'italic' + if ((bits & 2) !== 0) style.fontWeight = 'bold' + const decorations = DECORATION_BITS.filter(([bit]) => (bits & bit) !== 0) + if (decorations.length > 0) style.textDecoration = decorations.map(([, value]) => value).join(' ') + return style +} + +/** + * Narrow one tokenized line to the runs a `` renders, folding a + * whitespace-only run into the token that follows it — shiki's default + * `mergeWhitespaces` HTML behavior — with each run styled through + * {@link spanStyle}, so the streaming spans and the settled `codeToHtml` + * swap render one identical span tree. shiki exempts underlined/struck + * whitespace from the fold; under the css-variables theme that case cannot + * occur — its only underline rule styles inline-link scopes, whose spaced + * text tokenizes as one run, and it injects no strikethrough rule — so the + * unconditional fold here stays equivalent (the markdown arm-parity test + * pins it). A line-trailing whitespace-only run has no follower and keeps + * its own span, as in shiki. + */ +function lineSpans(line: ThemedToken[]): HighlightSpan[] { + const spans: HighlightSpan[] = [] + let pendingWhitespace = '' + for (const [index, token] of line.entries()) { + if (/^\s+$/.test(token.content) && index + 1 < line.length) { + pendingWhitespace += token.content + continue + } + spans.push({ text: pendingWhitespace + token.content, style: spanStyle(token) }) + pendingWhitespace = '' + } + return spans +} + +/** + * Incremental highlighter for one growing streaming fence. TextMate + * tokenization is line-based and forward-only — a line's tokens depend only on + * its own text and the grammar state entering it — so appended text never + * changes a completed line's tokens. The session caches the spans of every + * completed line together with the grammar state after them; each + * {@link update} tokenizes newly completed text from that state, plus the + * still-growing last line. Per-call cost therefore excludes the completed + * prefix, and the result equals a from-scratch tokenization of the same code. + * Non-append input and a change of resolved grammar reset the cache and + * re-tokenize fully, so any input stays correct. + */ +export class StreamingHighlightSession { + /** Grammar id the cache was built with; a different resolution resets it. */ + private resolved: string | undefined + /** Newline-terminated source prefix covered by {@link spans}. */ + private prefix = '' + /** Cached spans, one entry per completed line of {@link prefix}. */ + private spans: HighlightSpan[][] = [] + /** Grammar state after {@link prefix}; undefined = the grammar's initial state. */ + private state: GrammarState | undefined + private lastCode: string | undefined + private lastLang: string | undefined + private lastResult: HighlightSpan[][] | undefined + + private reset(resolved: string | undefined): void { + this.resolved = resolved + this.prefix = '' + this.spans = [] + this.state = undefined + } + + /** Tokenize `text` with `resolved`, resuming from the cached grammar state when one exists. */ + private tokenize(resolved: string, text: string): ThemedToken[][] { + return highlighter().codeToTokensBase(text, { + lang: resolved, + theme: 'css-variables', + ...(this.state === undefined ? {} : { grammarState: this.state }), + }) + } + + /** + * Tokenize the fence's current text into per-line highlighted runs; + * `undefined` means the caller renders its plain fallback. Idempotent per + * (`code`, `lang`) input — repeated calls return the identical result array — + * and a retained line keeps its span-array identity across growing calls, so + * a React caller can reuse cached line elements. A lazy grammar not yet + * loaded returns `undefined` and loads in the background exactly as + * {@link highlightToHtml} does; the next call after it registers highlights. + * @param code - the fence text accumulated so far (display-trimmed, no synthetic trailing newline). + * @param lang - the language hint (a markdown fence info string). + * @returns one entry per line of `code` (each an array of runs), or `undefined` for unknown or not-yet-loaded languages. + */ + update(code: string, lang: string | undefined): readonly HighlightSpan[][] | undefined { + if (code === this.lastCode && lang === this.lastLang && this.lastResult !== undefined) { + return this.lastResult + } + this.lastCode = code + this.lastLang = lang + const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase()) + if (resolved === undefined || !ensureGrammar(resolved)) { + this.reset(undefined) + this.lastResult = undefined + return undefined + } + if (resolved !== this.resolved || !code.startsWith(this.prefix)) this.reset(resolved) + const rest = code.slice(this.prefix.length) + const lastNewline = rest.lastIndexOf('\n') + // Everything before the last newline is newly completed lines: tokenize + // them once from the cached state and retain their spans. What follows is + // the still-growing line, re-tokenized per call but never retained. + if (lastNewline >= 0) { + // Tokenize what shiki's own line splitting would see: splitLines strips + // the \r of a \r\n terminator (interior pairs are shiki's to split), so + // a CRLF cut must not leak its \r into the last completed line — a bash + // continuation's grammar state, for example, differs with it. + const grownEnd = rest[lastNewline - 1] === '\r' ? lastNewline - 1 : lastNewline + const tokens = this.tokenize(resolved, rest.slice(0, grownEnd)) + // Per-line push, not one spread call: a reconnect can deliver the whole + // accumulated fence as one update, and spreading tens of thousands of + // lines into arguments can exceed the engine's argument limit. + for (const line of tokens) this.spans.push(lineSpans(line)) + this.state = highlighter().getLastGrammarState(tokens) + this.prefix = code.slice(0, this.prefix.length + lastNewline + 1) + } + this.lastResult = [...this.spans, ...this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans)] + return this.lastResult + } +} + /** * Tokenize `code` into per-line highlighted runs when `lang` maps to a * registered grammar; `undefined` means the caller renders its plain fallback. @@ -286,8 +426,9 @@ export interface HighlightSpan { * so this returns shiki's own 2D line/token structure narrowed to what a run * renders. Each run's color is a `--shiki-*` custom property, keeping token * colors on the theme package's sheets exactly as the HTML path does; the - * css-variables theme carries no font-style bits, matching that path's - * color-only output. The trailing newline shiki appends as a final empty line + * markup font-style bits the theme lets through (bold/italic/underline in + * markdown scopes) are dropped — the line-numbered file view renders + * color-only runs. The trailing newline shiki appends as a final empty line * is dropped so the run count matches the caller's own line array. * @param code - the source text. * @param lang - the language hint (a file-extension-derived language id). diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 55a9dadc42..950d85be52 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -126,7 +126,7 @@ export interface MarkdownFileMentions { * numbering accumulated in document order while references render. */ export interface MarkdownRenderContext { - /** Streaming arm: fences render plain and TeX stays literal. */ + /** Streaming arm: fences highlight incrementally as they grow; TeX (including ```math fences) stays literal until the settled pass. */ readonly streaming: boolean /** Localized fence copy-button labels. */ readonly labels: MarkdownLabels @@ -334,7 +334,13 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re // CodeBlock's display trim removes; feeding the bare value would make // that trim eat a REAL trailing blank line inside the fence instead. code={`${node.value}\n`} - lang={context.streaming ? undefined : lang} + lang={lang} + // Streaming keys are source offsets, stable while the fence grows, so + // the CodeBlock instance (and its incremental highlight session) + // survives every chunk. A fence whose info string is still mid-chunk + // has no content yet and took the empty-fence arm above, so `lang` + // here is final: it can never re-resolve to a different grammar. + streaming={context.streaming} copyLabel={context.labels.code.copyLabel} copiedLabel={context.labels.code.copiedLabel} /> diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt index 2047d32215..078279374b 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt @@ -3,12 +3,25 @@
+ #text "ts"