@morlay/session-rdb 0.0.16-alpha.1 → 0.0.16-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.mjs CHANGED
@@ -5116,7 +5116,7 @@ function manageArtifactAttachment(attachment) {
5116
5116
  if (attachment.body != null) attachment.bodyEncoding ??= "base64";
5117
5117
  }
5118
5118
  //#endregion
5119
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/utils.BX5Fg8C4.js
5119
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/utils.BX5Fg8C4.js
5120
5120
  const NAME_WORKER_STATE = "__vitest_worker__";
5121
5121
  function getWorkerState() {
5122
5122
  const workerState = globalThis[NAME_WORKER_STATE];
@@ -10019,10 +10019,10 @@ function offsetToLineNumber(source, offset) {
10019
10019
  return line + 1;
10020
10020
  }
10021
10021
  //#endregion
10022
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/_commonjsHelpers.D26ty3Ew.js
10022
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/_commonjsHelpers.D26ty3Ew.js
10023
10023
  var commonjsGlobal = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : {};
10024
10024
  //#endregion
10025
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/rpc.MzXet3jl.js
10025
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/rpc.MzXet3jl.js
10026
10026
  const RealDate = Date;
10027
10027
  let now = null;
10028
10028
  var MockDate = class MockDate extends RealDate {
@@ -10987,7 +10987,7 @@ function raceWith(promise, other) {
10987
10987
  }))]);
10988
10988
  }
10989
10989
  //#endregion
10990
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/test.DNmyFkvJ.js
10990
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/test.DNmyFkvJ.js
10991
10991
  var fakeTimersSrc = {};
10992
10992
  var global$1;
10993
10993
  var hasRequiredGlobal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.16-alpha.1",
3
+ "version": "0.0.16-alpha.4",
4
4
  "description": "RDB durable session backend for DeepSeek Harness: implements both session-persistence and session-branch (rewind / retry / fork) providers.",
5
5
  "keywords": [
6
6
  "branch",
@@ -35,27 +35,32 @@
35
35
  "./cordis.patch.yml": "./cordis.patch.yml"
36
36
  },
37
37
  "dependencies": {
38
- "@morlay/session-branch": "^0.0.4-alpha.1",
38
+ "@morlay/session-branch": "^0.0.4-alpha.4",
39
39
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
40
40
  "fflate": "^0.8.2",
41
41
  "pg": "^8.23.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@deepseek-ai/dsh-session-projection": "^0.1.3-alpha.2",
45
- "@deepseek-ai/dsh-token-meter": "^0.1.3-alpha.2",
44
+ "@deepseek-ai/dsh-session-projection": "^0.1.5-alpha.2",
45
+ "@deepseek-ai/dsh-session-projection-cache": "^0.1.5-alpha.2",
46
+ "@deepseek-ai/dsh-session-turn-outline": "^0.1.5-alpha.2",
47
+ "@deepseek-ai/dsh-storage": "^0.1.5-alpha.2",
48
+ "@deepseek-ai/dsh-storage-domain": "^0.1.5-alpha.2",
49
+ "@deepseek-ai/dsh-storage-json": "^0.1.5-alpha.2",
50
+ "@deepseek-ai/dsh-token-meter": "^0.1.5-alpha.2",
46
51
  "@types/pg": "^8.23.1",
47
52
  "drizzle-kit": "1.0.0-rc.5-ab785fc"
48
53
  },
49
54
  "peerDependencies": {
50
55
  "@deepseek-ai/cordis": "^4.0.2",
51
- "@deepseek-ai/dsh-invariants": "^0.1.3-alpha.2",
52
- "@deepseek-ai/dsh-llm": "^0.1.3-alpha.2",
53
- "@deepseek-ai/dsh-scope": "^0.1.3-alpha.2",
54
- "@deepseek-ai/dsh-session": "^0.1.3-alpha.2",
55
- "@deepseek-ai/dsh-session-format": "^0.1.3-alpha.2",
56
- "@deepseek-ai/dsh-session-format-catalog": "^0.1.3-alpha.2",
57
- "@deepseek-ai/dsh-session-persistence": "^0.1.3-alpha.2",
58
- "@deepseek-ai/dsh-settings": "^0.1.3-alpha.2",
56
+ "@deepseek-ai/dsh-invariants": "^0.1.5-alpha.2",
57
+ "@deepseek-ai/dsh-llm": "^0.1.5-alpha.2",
58
+ "@deepseek-ai/dsh-scope": "^0.1.5-alpha.2",
59
+ "@deepseek-ai/dsh-session": "^0.1.5-alpha.2",
60
+ "@deepseek-ai/dsh-session-format": "^0.1.5-alpha.2",
61
+ "@deepseek-ai/dsh-session-format-catalog": "^0.1.5-alpha.2",
62
+ "@deepseek-ai/dsh-session-persistence": "^0.1.5-alpha.2",
63
+ "@deepseek-ai/dsh-settings": "^0.1.5-alpha.2",
59
64
  "@deepseek-ai/schemastery": "^3.18.2"
60
65
  },
61
66
  "dsh": {
package/src/branch.ts CHANGED
@@ -66,12 +66,45 @@ export interface LiveSessionHooks {
66
66
  getAgent(id: SessionId): LiveAgentLike | undefined;
67
67
 
68
68
  flush(session: Session): Promise<boolean>;
69
+
70
+ // 丢弃 live 会话已缓存的投影单元:rewind 直接截断内存 log,投影 registry
71
+ // 的 observedSeq 仍停在截断前,后续 append 的事件(seq 更小)会被 drive()
72
+ // 跳过,重放输入永远进不了 inbox 投影。可选:纯持久化环境没有投影服务。
73
+ resetProjections?(session: Session): void;
74
+
75
+ // 用截断后的 log 重写该会话的持久化投影检查点:rewind 不会产生事件,
76
+ // 缓存行的水位(以及基于它折出的值)仍停在截断前。超前行不会被前端投影
77
+ // store 的 higher-seq-wins 规则覆盖(rewind 后的正确值 seq 更小),表现为
78
+ // 轮次导航残留被删除的旧轮次。可选:纯持久化环境没有投影缓存服务。
79
+ refreshProjectionCache?(session: ProjectionCacheSession): Promise<void>;
80
+ }
81
+
82
+ /**
83
+ * 投影检查点刷新所需的最小会话面:cold rewind 没有 live Session 可复用,
84
+ * 而截断后的前缀未必是合法的独立会话(surface 引用可能悬空),不能走
85
+ * `Session.create` 的校验;缓存服务只读 id / header / inheritedEventCount
86
+ * 与事件前缀,用普通对象承载即可。
87
+ */
88
+ export interface ProjectionCacheSession {
89
+ readonly id: SessionId;
90
+ readonly header: SessionHeader;
91
+ readonly inheritedEventCount: SessionLogOffset;
92
+ snapshotEvents(): readonly SessionEvent[];
93
+ }
94
+
95
+ // 投影 registry 的失效面(上游私有结构,duck-type 读取)。
96
+ interface ProjectionRegistryLike {
97
+ registrations?: Map<string, { cells: WeakMap<object, unknown> }>;
69
98
  }
70
99
 
71
100
  export interface LiveAgentLike {
72
101
  session: Session;
73
102
 
74
103
  requestHeaderLogged?: boolean;
104
+
105
+ // 上游 Agent 的 inbox 契约面(ReactLoopInbox.clear):rewind 后残留的排队
106
+ // 输入必须 durable 取消,否则 agent 会继续处理它们。可选:测试替身可能没有。
107
+ inbox?: { clear(): void };
75
108
  }
76
109
 
77
110
  export function truncateLiveSession(session: Session, newLength: number): void {
@@ -246,8 +279,12 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
246
279
  if (live !== undefined) await this.live.flush(live);
247
280
  // 边界校验用原始事件(readLog,不补 closers)——inspect 会给未闭合
248
281
  // log 补合成 closers,把 user/message 边界掩盖成 turn/end,丢失 exclusive
249
- // 语义。
250
- const raw = live === undefined ? await this.readRawEvents(id, signal) : undefined;
282
+ // 语义。cold 分支同时取回 inheritedEventCount:刷新投影检查点时要用它
283
+ // 重建与存储一致的会话身份。
284
+ const raw = live === undefined ? await this.persistence.readLog(id, {}, signal) : undefined;
285
+ if (live === undefined && raw === undefined) {
286
+ throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
287
+ }
251
288
  const inspection =
252
289
  live === undefined ? undefined : await this.persistence.internals().inspect(id, signal);
253
290
  const events = live === undefined ? raw!.events : inspection!.events;
@@ -270,7 +307,8 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
270
307
  // 编辑版替换)。exclusive 截断可能残留孤儿 step/start,经平衡化剔除。
271
308
  const rawKeepLength =
272
309
  toBoundary === -1 ? 0 : boundaryEvent!.type === "turn/end" ? toBoundary + 1 : toBoundary;
273
- const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
310
+ const kept = balanceRewindPrefix(events.slice(0, rawKeepLength));
311
+ const keepLength = kept.length;
274
312
 
275
313
  const internals = this.persistence.internals();
276
314
  // 原样存储(无过滤):live 视图与 RDB head 同空间(上游 seq),
@@ -314,6 +352,9 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
314
352
  // agent 轮次游标。不调用 load——live 时 load 会先 flush 把旧内存写回,
315
353
  // 撤销本次截断。
316
354
  truncateLiveSession(live, keepLength);
355
+ // 投影缓存同样停在截断前的水位,必须先失效,否则截断后的重放事件
356
+ // (seq 回退)不会进入投影。
357
+ this.live.resetProjections?.(live);
317
358
  const agent = this.live.getAgent(id);
318
359
  if (agent !== undefined) {
319
360
  agent.requestHeaderLogged = false;
@@ -330,6 +371,24 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
330
371
  if (handle !== undefined) {
331
372
  handle.resetAfterRewind(keepLength, newSeedLength === null ? undefined : newSeedLength);
332
373
  }
374
+ // 截断后强制清空排队输入:保留区里未被截断的 pending(`agent/inbox/spliced`)
375
+ // 仍会被 agent 处理,必须 durable 取消。cursor 已对齐,取消事件从截断点续接。
376
+ agent?.inbox?.clear();
377
+ // 取消事件先经 coordinator 缓冲,落盘后 rewind 才真正 durable(调用方
378
+ // 可能在 rewind 返回后直接读后端)。
379
+ await this.live.flush(live);
380
+ // 持久化投影检查点也要退到截断后的水位:rewind 不产生事件,缓存行
381
+ // 会停在截断前;超前行锁死前端(higher-seq-wins),轮次导航残留旧轮次。
382
+ await this.refreshProjectionCache(live);
383
+ } else {
384
+ // cold:用截断后的前缀 + 存储身份构造最小会话面,让缓存服务按截断后
385
+ // 的 log 重写检查点(没有 live 会话可复用,也不为此 resume 一个 agent)。
386
+ await this.refreshProjectionCache({
387
+ id,
388
+ header: meta,
389
+ inheritedEventCount: SessionLogOffset(raw!.inheritedEventCount),
390
+ snapshotEvents: () => kept,
391
+ });
333
392
  }
334
393
 
335
394
  const row = await internals.backend.getSession(id);
@@ -354,6 +413,19 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
354
413
  }
355
414
  return { header: rowToMeta(row), revision: (await internals.readStoredRevision(id))! };
356
415
  }
416
+
417
+ /**
418
+ * 让持久化投影检查点跟随截断后的 log(见 {@link LiveSessionHooks.refreshProjectionCache})。
419
+ * 缓存是派生数据:刷新失败只让缓存多滞后一轮,不能让 rewind 失败。
420
+ */
421
+ private async refreshProjectionCache(session: ProjectionCacheSession): Promise<void> {
422
+ if (this.live.refreshProjectionCache === undefined) return;
423
+ try {
424
+ await this.live.refreshProjectionCache(session);
425
+ } catch {
426
+ // 实现方(SessionBranchRdb)已记日志;此处只保证 rewind 不因派生数据失败。
427
+ }
428
+ }
357
429
  }
358
430
 
359
431
  export class SessionBranchRdb extends SessionBranch {
@@ -375,6 +447,30 @@ export class SessionBranchRdb extends SessionBranch {
375
447
  return agents?.get(id);
376
448
  },
377
449
  flush: (session) => this.ctx.sessions.flush(session),
450
+ resetProjections: (session) => {
451
+ // sessionProjections 是可选服务(纯持久化环境无 agent-loop/投影)。
452
+ const registry = (this.ctx as unknown as { get(name: string): unknown }).get(
453
+ "sessionProjections",
454
+ ) as ProjectionRegistryLike | undefined;
455
+ if (registry?.registrations === undefined) return;
456
+ for (const registration of registry.registrations.values()) {
457
+ registration.cells.delete(session);
458
+ }
459
+ },
460
+ refreshProjectionCache: async (session) => {
461
+ // sessionProjectionCache 是可选服务(纯持久化环境无投影缓存)。
462
+ const cache = this.ctx.get("sessionProjectionCache") as
463
+ | { write(session: ProjectionCacheSession): Promise<void> }
464
+ | undefined;
465
+ if (cache === undefined) return;
466
+ try {
467
+ await cache.write(session);
468
+ } catch (error: unknown) {
469
+ this.ctx.logger.warn(
470
+ `session-rdb: projection cache refresh after rewind for "${session.id}" failed (cache stays stale): ${String(error)}`,
471
+ );
472
+ }
473
+ },
378
474
  },
379
475
  );
380
476
 
package/src/index.ts CHANGED
@@ -560,6 +560,10 @@ export class SessionPersistenceRdb extends SessionPersistence {
560
560
  }
561
561
  const log = await this.readLog(id, {}, options?.signal);
562
562
  if (log === undefined) throw new SessionPersistenceNotFoundError(id);
563
+ // 读取视图修复必须先于校验:越界 replace(旧写入器重编号遗留的旧坐标)
564
+ // 降级/夹取、request/header 归一,否则上游事件校验 fail loud 拒绝整个
565
+ // 会话(load / open 是历史会话的加载入口,不能只依赖 handle.read)。
566
+ repairReadView(log.events);
563
567
  // fail-closed:未知事件类型(非 ignorable)拒绝解释。
564
568
  validateStoredEvents(log.meta, log.events);
565
569
  return this.tracker.adopt(
@@ -584,6 +588,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
584
588
  }
585
589
  const log = await this.readLog(id, {}, options?.signal);
586
590
  if (log === undefined) throw new SessionPersistenceNotFoundError(id);
591
+ repairReadView(log.events);
587
592
  validateStoredEvents(log.meta, log.events);
588
593
  // 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件坐标与
589
594
  // 存储桥接行数不再相等——写打开时把迁移视图整体落库,使读写同坐标;
@@ -692,9 +697,9 @@ export class SessionPersistenceRdb extends SessionPersistence {
692
697
  }
693
698
 
694
699
  /** 原样 append 落库(handle 已校验 contiguity;torn tail 先截断)。
695
- * 与上游 JSONL 一致:ignorable 事件原样存储,不做过滤。写路径校验 v2
696
- * 形状(fail-closed):未知类型(非 ignorable)与非法消息形状拒绝入库;
697
- * 旧格式(v0/v1)数据只在读取时经 legacy 转换链动态转换,不落新库。 */
700
+ * 与上游 JSONL 一致:ignorable 事件原样存储,不做过滤。写路径校验当前
701
+ * 格式形状(fail-closed):未知类型(非 ignorable)与非法消息形状拒绝入库;
702
+ * 非当前格式(v0/v1/v2)数据只在读取时经 legacy 转换链动态转换,不落新库。 */
698
703
  async appendBatch(
699
704
  meta: SessionHeader,
700
705
  inheritedEventCount: SessionLogOffset,
@@ -703,8 +708,8 @@ export class SessionPersistenceRdb extends SessionPersistence {
703
708
  ): Promise<boolean> {
704
709
  await this.ready;
705
710
  if (events.length === 0) return false;
706
- // 写路径 v2 校验:与读路径同契约(validateStoredEvents),保证新入库
707
- // 数据只能是 v2 形状。拷贝避免 adopt 替换污染调用方数组。
711
+ // 写路径当前格式校验:与读路径同契约(validateStoredEvents),保证新入库
712
+ // 数据只能是当前格式形状。拷贝避免 adopt 替换污染调用方数组。
708
713
  validateStoredEvents(meta, [...events]);
709
714
  // fork 派生会话的 seed 复用源会话事件行(不复制);消费后清除。
710
715
  const reuse = this.reuseEventIds.get(meta.id);
@@ -763,7 +768,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
763
768
  /** 存储桥接行数(迁移链可能生成/合并事件,与 `events.length` 不同)。 */
764
769
  storedCount: number;
765
770
 
766
- /** 是否经上游迁移链转换(v0/v1 → 当前格式)。 */
771
+ /** 是否经上游迁移链转换(非当前格式 → 当前格式)。 */
767
772
  migrated: boolean;
768
773
  }
769
774
  | undefined
@@ -779,10 +784,11 @@ export class SessionPersistenceRdb extends SessionPersistence {
779
784
  ? await this.backend.getEventRows(id)
780
785
  : await this.backend.getEventRows(id, options.fromSeq);
781
786
  signal?.throwIfAborted();
782
- // 旧格式(v0/v1)历史数据:行重建为物理记录,经上游迁移链转 v2 逻辑事件。
783
- // 迁移链自带 seq gap / torn tail 校验(strict recovery),无需 scanRows。
784
- // 混合世代 log(旧写入器跨上游版本追加)不是任何单一已发布格式,迁移链
785
- // 必然拒绝——回退为当前格式视图(header 版本归一 + 读取视图修复)。
787
+ // 非当前格式(v0/v1/v2)历史数据:行重建为物理记录,经上游迁移链转
788
+ // 当前逻辑事件。迁移链自带 seq gap / torn tail 校验(strict recovery),
789
+ // 无需 scanRows。混合世代 log(旧写入器跨上游版本追加)不是任何单一已
790
+ // 发布格式,迁移链必然拒绝——回退为当前格式视图(header 版本归一 +
791
+ // 读取视图修复)。
786
792
  if (isLegacyVersion(row.fVersion)) {
787
793
  try {
788
794
  const converted = convertLegacyRows(row, eventRows);
@@ -826,7 +832,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
826
832
  }
827
833
 
828
834
  /**
829
- * 把迁移链读出的 v2 视图整体落库(旧格式会话写打开时的一次性迁移)。
835
+ * 把迁移链读出的当前格式视图整体落库(非当前格式会话写打开时的一次性迁移)。
830
836
  *
831
837
  * 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件 seq 空间
832
838
  * 与存储桥接行数不再相等;写路径以存储 head 为锚点重编号,二者不一致会让
@@ -927,8 +933,8 @@ export class SessionPersistenceRdb extends SessionPersistence {
927
933
  );
928
934
  const row = await this.backend.getSession(id);
929
935
  if (row === undefined) throw new SessionPersistenceNotFoundError(id);
930
- // 旧格式(v0/v1)会话:meta 与继承前缀来自转换链(handle.header 已是
931
- // 转换后的 v2 header);v2 会话用存储行。
936
+ // 非当前格式会话:meta 与继承前缀来自转换链(handle.header 已是转换后的
937
+ // 当前格式 header);当前格式会话用存储行。
932
938
  if (isLegacyVersion(row.fVersion)) {
933
939
  return {
934
940
  meta: handle.header,
package/src/legacy.ts CHANGED
@@ -1,5 +1,5 @@
1
- // 旧格式(v0/v1)历史数据读取转换:RDB 行 → v0/v1 物理记录 → 上游迁移链 →
2
- // v2 逻辑事件。RDB 写路径从不持久化 sourceEventSeqs(读取时重计算),且
1
+ // 非当前格式(v0/v1/v2)历史数据读取转换:RDB 行 → 物理记录 → 上游迁移链 →
2
+ // 当前逻辑事件。RDB 写路径从不持久化 sourceEventSeqs(读取时重计算),且
3
3
  // delta(assistant/chunk)落盘被过滤——v1→v2 迁移对无 chunk 引用的
4
4
  // assistant/message 走「无 pending 直接 emit」路径,生成 stream: [] 的 v2 事件。
5
5
 
@@ -11,39 +11,52 @@ import {
11
11
  } from "@deepseek-ai/dsh-session";
12
12
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
13
13
  import type { EventRow, SessionRow } from "./backend.ts";
14
- import { rowToMeta, scanRows } from "./log.ts";
14
+ import { repairRequestHeaders, rowToMeta, scanRows } from "./log.ts";
15
15
 
16
- /** 重建 v0/v1 物理 header 记录(RDB 行 → 物理 JSON 对象)。 */
16
+ /** 重建 v0/v1/v2 物理 header 记录(RDB 行 → 物理 JSON 对象)。 */
17
17
  function physicalHeader(row: SessionRow): Record<string, unknown> {
18
- return {
18
+ const common = {
19
19
  type: "session",
20
20
  version: row.fVersion,
21
21
  id: row.fSessionId,
22
22
  createdAt: row.fCreatedAt,
23
23
  ...(row.fCwd !== null ? { cwd: row.fCwd } : {}),
24
24
  ...(row.fParentSession !== null ? { parentSession: row.fParentSession } : {}),
25
- ...(row.fSeedLength !== null ? { seedLength: row.fSeedLength } : {}),
26
25
  ...(row.fOrigin !== null ? { origin: row.fOrigin } : {}),
27
26
  delegationDepth: row.fDelegationDepth ?? 0,
28
27
  };
28
+ // v0/v1 物理 header 用 seedLength 表达继承前缀;v2 起改用 isSeeded,
29
+ // 前缀长度由 log 里的 session/end-seed 标记推导。
30
+ return row.fVersion < 2
31
+ ? { ...common, ...(row.fSeedLength !== null ? { seedLength: row.fSeedLength } : {}) }
32
+ : { ...common, isSeeded: row.fSeedLength !== null };
29
33
  }
30
34
 
31
- /** 重建一条 v0/v1 物理事件记录(桥接行 + 事件行 → 物理 JSON 对象)。 */
35
+ /** 重建一条 v0/v1/v2 物理事件记录(桥接行 + 事件行 → 物理 JSON 对象)。 */
32
36
  function physicalEvent(row: EventRow): Record<string, unknown> {
37
+ const stored = JSON.parse(row.fData) as unknown;
38
+ // 两种存储形状:当前写入器落完整事件信封(与 JSONL 每行同构),schema v2
39
+ // 时代只落 data 部分。迁移链要的是 payload,故信封形状取 `data`。
40
+ const full =
41
+ typeof stored === "object" &&
42
+ stored !== null &&
43
+ !Array.isArray(stored) &&
44
+ typeof (stored as Record<string, unknown>)["type"] === "string" &&
45
+ "data" in (stored as Record<string, unknown>);
33
46
  return {
34
47
  type: row.fType,
35
48
  seq: row.fSequence,
36
49
  time: row.fCreatedAt,
37
- data: JSON.parse(row.fData) as unknown,
50
+ data: full ? (stored as Record<string, unknown>)["data"] : stored,
38
51
  ...(row.fSurfaceOp !== null ? { surfaceOp: JSON.parse(row.fSurfaceOp) as unknown } : {}),
39
52
  };
40
53
  }
41
54
 
42
55
  /**
43
- * 把旧格式(v0/v1)行转换为 v2 逻辑事件与 header。
44
- * @param row - 会话行(f_version < 2)。
56
+ * 把非当前格式(v0/v1/v2)行转换为当前逻辑事件与 header。
57
+ * @param row - 会话行(f_version < SESSION_FORMAT_VERSION)。
45
58
  * @param eventRows - 已扫描保留的桥接行(稠密 seq,torn tail 已剔除)。
46
- * @returns v2 逻辑 header、继承前缀长度与事件。
59
+ * @returns 当前逻辑 header、继承前缀长度与事件。
47
60
  * @throws 迁移链拒绝(格式损坏 / 无法迁移)时原样抛出。
48
61
  */
49
62
  export function convertLegacyRows(
@@ -65,9 +78,9 @@ export function convertLegacyRows(
65
78
  };
66
79
  }
67
80
 
68
- /** 会话行是否携带旧格式(v0/v1)数据。 */
81
+ /** 会话行是否携带非当前格式(f_version < SESSION_FORMAT_VERSION)数据。 */
69
82
  export function isLegacyVersion(version: number): boolean {
70
- return version < 2;
83
+ return version < SESSION_FORMAT_VERSION;
71
84
  }
72
85
 
73
86
  /**
@@ -82,7 +95,7 @@ export function isLegacyVersion(version: number): boolean {
82
95
  * 调用方须在此之后执行 `validateStoredEvents`:真正属于旧格式的类型
83
96
  * (`assistant/chunk` / `compact/*` 等)仍会被 fail-loud 拒绝。
84
97
  *
85
- * @param row - 会话行(f_version < 2)。
98
+ * @param row - 会话行(f_version < SESSION_FORMAT_VERSION)。
86
99
  * @param eventRows - 该会话全部桥接行(按 f_sequence 升序)。
87
100
  * @returns 当前格式 header(version 归一到 SESSION_FORMAT_VERSION)、收缩后的
88
101
  * 继承前缀长度、稠密事件与可选 torn tail 起点。
@@ -98,6 +111,11 @@ export function adoptLegacyRows(
98
111
  tornFrom?: number;
99
112
  } {
100
113
  const { preserved, tornFrom } = scanRows(eventRows, 0);
114
+ // 旧格式 request/header 归一为当前格式:v3 起 system prompt 由 surface 上的
115
+ // system/message 承载,header 必须省略 system / 空 tools / 空 adapterDefaults
116
+ // (上游事件校验 fail loud)。归一必须发生在调用方 `validateStoredEvents`
117
+ // 之前,否则整个会话加载失败。
118
+ repairRequestHeaders(preserved);
101
119
  return {
102
120
  meta: { ...rowToMeta(row), version: SESSION_FORMAT_VERSION },
103
121
  // 继承前缀不得超过事件数(历史 rewind 未收缩的损坏样式),上游 load 判损坏。