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

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.
@@ -45,8 +45,32 @@ declare function findSurfaceRepairs(events: readonly SessionEvent[]): {
45
45
  degradeToAppend: Set<number>;
46
46
  addAppendMarker: Set<number>;
47
47
  clearSurfaceOp: Set<number>;
48
+ clampEnd: Map<number, number>;
48
49
  };
49
50
  declare function repairSurfaceOps(events: SessionEvent[]): void;
51
+ /**
52
+ * 读取时补全 assistant 结算字段:旧写入器不落库 `stream`(v2 才把流式记录
53
+ * 嵌入消息),读回时缺失即补空数组——上游 seed 校验要求 turn/step/stream
54
+ * 三者齐备,缺失会让整个会话加载失败。
55
+ */
56
+ declare function repairAssistantSettlement(events: SessionEvent[]): void;
57
+ /**
58
+ * 把紧邻 replace 的 metering 事件的 `shadowedRange` / `shadowedSeqs` 对齐到
59
+ * replace 最终的稠密 range。
60
+ *
61
+ * 旧写入器重编号事件后,metering 与 replace 一起落在旧坐标空间:二者数值
62
+ * 相等但与当前 surface 无关。夹取(或 range 本身已落在稠密空间而 metering
63
+ * 仍是旧值)后二者不再相等,而上游 token-meter 契约要求紧邻的 metering
64
+ * range 与 replace range 完全一致(否则报 no adjacent shadow price)。range
65
+ * 已一致时不改写——当前写入器保证一致,shadowedSeqs 的额外并发节点因此
66
+ * 原样保留。
67
+ */
68
+ declare function syncMeteringRanges(events: SessionEvent[]): void;
69
+ /**
70
+ * 读取视图修复总入口:结算字段补全 → surface 语义修复 → metering 对齐 →
71
+ * provenance 重算 → 孤儿 inbox splice 改写。全部只作用于内存视图,不落库。
72
+ */
73
+ declare function repairReadView(events: SessionEvent[]): void;
50
74
  /**
51
75
  * 定位无法从空状态增量重放的 agent/inbox/spliced 事件(孤儿操作)。
52
76
  *
@@ -64,4 +88,4 @@ declare function scanRows(rows: readonly EventRow[], base?: number): {
64
88
  };
65
89
  declare function toJsonlArtifact(meta: SessionHeader, inheritedEventCount: number, events: readonly SessionEvent[]): string;
66
90
  //#endregion
67
- export { SESSION_IMPORT_PATH, SESSION_LOG_ARTIFACT_FILENAME, findSurfaceRepairs, orphanInboxSpliceSeqs, parseImportZip, parseJsonlArtifact, persistImport, recomputeReplaceProvenance, registerSessionImport, repairOrphanInboxSplices, repairSurfaceOps, rowToEvent, rowToMeta, scanRows, sessionConflictRow, sessionInsertRow, toJsonlArtifact };
91
+ export { SESSION_IMPORT_PATH, SESSION_LOG_ARTIFACT_FILENAME, findSurfaceRepairs, orphanInboxSpliceSeqs, parseImportZip, parseJsonlArtifact, persistImport, recomputeReplaceProvenance, registerSessionImport, repairAssistantSettlement, repairOrphanInboxSplices, repairReadView, repairSurfaceOps, rowToEvent, rowToMeta, scanRows, sessionConflictRow, sessionInsertRow, syncMeteringRanges, toJsonlArtifact };
package/dist/artifact.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { a as repairSurfaceOps, c as scanRows, d as toJsonlArtifact, i as repairOrphanInboxSplices, l as sessionConflictRow, n as orphanInboxSpliceSeqs, o as rowToEvent, r as recomputeReplaceProvenance, s as rowToMeta, t as findSurfaceRepairs, u as sessionInsertRow } from "./log-DTJsxMud.mjs";
2
- import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-CKrzjXVB.mjs";
3
- export { SESSION_IMPORT_PATH, SESSION_LOG_ARTIFACT_FILENAME, findSurfaceRepairs, orphanInboxSpliceSeqs, parseImportZip, parseJsonlArtifact, persistImport, recomputeReplaceProvenance, registerSessionImport, repairOrphanInboxSplices, repairSurfaceOps, rowToEvent, rowToMeta, scanRows, sessionConflictRow, sessionInsertRow, toJsonlArtifact };
1
+ import { a as repairOrphanInboxSplices, c as rowToEvent, d as sessionConflictRow, f as sessionInsertRow, i as repairAssistantSettlement, l as rowToMeta, m as toJsonlArtifact, n as orphanInboxSpliceSeqs, o as repairReadView, p as syncMeteringRanges, r as recomputeReplaceProvenance, s as repairSurfaceOps, t as findSurfaceRepairs, u as scanRows } from "./log-DBFUBPhv.mjs";
2
+ import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-mZZgDBLd.mjs";
3
+ export { SESSION_IMPORT_PATH, SESSION_LOG_ARTIFACT_FILENAME, findSurfaceRepairs, orphanInboxSpliceSeqs, parseImportZip, parseJsonlArtifact, persistImport, recomputeReplaceProvenance, registerSessionImport, repairAssistantSettlement, repairOrphanInboxSplices, repairReadView, repairSurfaceOps, rowToEvent, rowToMeta, scanRows, sessionConflictRow, sessionInsertRow, syncMeteringRanges, toJsonlArtifact };
@@ -1,4 +1,4 @@
1
- import { s as rowToMeta } from "./log-DTJsxMud.mjs";
1
+ import { l as rowToMeta } from "./log-DBFUBPhv.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-session";
4
4
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
package/dist/import.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as SessionPersistenceRdb } from "./index-CgGDHQSK.mjs";
1
+ import { n as SessionPersistenceRdb } from "./index-GdKkSSb-.mjs";
2
2
  import { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
3
3
  import { Session, SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
4
4
  import { Context } from "@deepseek-ai/cordis";
package/dist/import.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-CKrzjXVB.mjs";
1
+ import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-mZZgDBLd.mjs";
2
2
  export { SESSION_IMPORT_PATH, SESSION_LOG_ARTIFACT_FILENAME, parseImportZip, parseJsonlArtifact, persistImport, registerSessionImport };
@@ -196,7 +196,21 @@ declare class SessionPersistenceRdb extends SessionPersistence {
196
196
  tornFrom?: number;
197
197
  incarnation: string;
198
198
  revision: number;
199
+ /** 存储桥接行数(迁移链可能生成/合并事件,与 `events.length` 不同)。 */
200
+ storedCount: number;
201
+ /** 是否经上游迁移链转换(v0/v1 → 当前格式)。 */
202
+ migrated: boolean;
199
203
  } | undefined>;
204
+ /**
205
+ * 把迁移链读出的 v2 视图整体落库(旧格式会话写打开时的一次性迁移)。
206
+ *
207
+ * 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件 seq 空间
208
+ * 与存储桥接行数不再相等;写路径以存储 head 为锚点重编号,二者不一致会让
209
+ * append 撞上已有行。这里在同一事务内删光本会话桥接行、按迁移视图重建
210
+ * (新事件行,完整信封),并更新 head 与 revision;旧事件行保留(可能被
211
+ * fork 子会话引用,孤儿由惰性 GC 处理)。
212
+ */
213
+ private rewriteMigratedLog;
200
214
  listSnapshots(signal?: AbortSignal): Promise<Array<SessionPersistenceSnapshot & {
201
215
  inheritedEventCount: number;
202
216
  }>>;
package/dist/index.d.mts CHANGED
@@ -1,3 +1,3 @@
1
1
  import { o as SCHEMA_VERSION } from "./schema-COK7-wRV.mjs";
2
- import { a as SessionBranchRdbProvider, i as SessionBranchRdb, n as SessionPersistenceRdb, o as locateTurnEnd, r as SessionPersistenceRdbInternals, t as Config } from "./index-CgGDHQSK.mjs";
2
+ import { a as SessionBranchRdbProvider, i as SessionBranchRdb, n as SessionPersistenceRdb, o as locateTurnEnd, r as SessionPersistenceRdbInternals, t as Config } from "./index-GdKkSSb-.mjs";
3
3
  export { Config, SCHEMA_VERSION, SessionBranchRdb, SessionBranchRdbProvider, SessionPersistenceRdb, SessionPersistenceRdb as default, SessionPersistenceRdbInternals, locateTurnEnd };
package/dist/index.mjs CHANGED
@@ -1,12 +1,12 @@
1
- import { a as SCHEMA_VERSION, g as WriteGuard, h as toPostgresSchema, i as EVENT_ENCODING, m as postgresTableDefs, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend } from "./sqlite-DCy4VTD8.mjs";
2
- import { a as repairSurfaceOps, c as scanRows, d as toJsonlArtifact, i as repairOrphanInboxSplices, l as sessionConflictRow, r as recomputeReplaceProvenance, s as rowToMeta, u as sessionInsertRow } from "./log-DTJsxMud.mjs";
3
- import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-CKrzjXVB.mjs";
1
+ import { a as SCHEMA_VERSION, g as WriteGuard, h as toPostgresSchema, i as EVENT_ENCODING, m as postgresTableDefs, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend } from "./sqlite-CnWwGToZ.mjs";
2
+ import { d as sessionConflictRow, f as sessionInsertRow, l as rowToMeta, m as toJsonlArtifact, o as repairReadView, u as scanRows } from "./log-DBFUBPhv.mjs";
3
+ import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-mZZgDBLd.mjs";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { Pool } from "pg";
7
7
  import { drizzle } from "drizzle-orm/node-postgres";
8
8
  import { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionHandleClosedError, SessionPersistence, SessionPersistenceNotFoundError, SessionPersistenceRevision, SessionReadOnlyError, assertContiguous, assertVersion, materializeAppendBatch, materializeCreateHeader, validateStoredEvents } from "@deepseek-ai/dsh-session-persistence";
9
- import { SessionLogOffset } from "@deepseek-ai/dsh-session";
9
+ import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-session";
10
10
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
11
11
  import { and, eq, gte, sql } from "drizzle-orm";
12
12
  import { readdirSync } from "node:fs";
@@ -209,6 +209,36 @@ function convertLegacyRows(row, eventRows) {
209
209
  function isLegacyVersion(version) {
210
210
  return version < 2;
211
211
  }
212
+ /**
213
+ * 迁移链拒绝后,把旧格式行直接当**当前格式**事件视图采用。
214
+ *
215
+ * 旧写入器只在建会话时落一次 header version,之后跟随上游演进继续追加
216
+ * 事件——同一个 log 因此可能是混合世代(v0 时代的行 + 新版本字段),不是
217
+ * 任何单一已发布格式,严格迁移链必然拒绝。这些行的数据形状(消息包装、
218
+ * 类型名)已经是当前格式,只需 header 版本归一到当前、按稠密 seq 读取;
219
+ * 事件数据的补全与 surface 修复由读取视图修复(repairReadView)负责。
220
+ *
221
+ * 调用方须在此之后执行 `validateStoredEvents`:真正属于旧格式的类型
222
+ * (`assistant/chunk` / `compact/*` 等)仍会被 fail-loud 拒绝。
223
+ *
224
+ * @param row - 会话行(f_version < 2)。
225
+ * @param eventRows - 该会话全部桥接行(按 f_sequence 升序)。
226
+ * @returns 当前格式 header(version 归一到 SESSION_FORMAT_VERSION)、收缩后的
227
+ * 继承前缀长度、稠密事件与可选 torn tail 起点。
228
+ * @throws scanRows 的已提交区损坏错误(seq gap / 不可解析行)。
229
+ */
230
+ function adoptLegacyRows(row, eventRows) {
231
+ const { preserved, tornFrom } = scanRows(eventRows, 0);
232
+ return {
233
+ meta: {
234
+ ...rowToMeta(row),
235
+ version: SESSION_FORMAT_VERSION
236
+ },
237
+ inheritedEventCount: Math.min(row.fSeedLength ?? 0, preserved.length),
238
+ events: preserved,
239
+ ...tornFrom !== void 0 ? { tornFrom } : {}
240
+ };
241
+ }
212
242
  //#endregion
213
243
  //#region src/index.ts
214
244
  /** 会话级写所有权与 live 路由簿记。 */
@@ -352,9 +382,7 @@ var RdbSessionHandle = class RdbSessionHandle {
352
382
  };
353
383
  throw new SessionPersistenceNotFoundError(this.id);
354
384
  }
355
- repairSurfaceOps(log.events);
356
- recomputeReplaceProvenance(log.events);
357
- repairOrphanInboxSplices(log.events);
385
+ repairReadView(log.events);
358
386
  return {
359
387
  eventState: "detached",
360
388
  events: log.events.slice(offset, offset + length)
@@ -567,6 +595,7 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
567
595
  const log = await this.readLog(id, {}, options?.signal);
568
596
  if (log === void 0) throw new SessionPersistenceNotFoundError(id);
569
597
  validateStoredEvents(log.meta, log.events);
598
+ if (log.migrated && log.events.length !== log.storedCount) await this.rewriteMigratedLog(id, log);
570
599
  this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
571
600
  return this.tracker.adopt(new RdbSessionHandle(this, id, log.meta, "write", {
572
601
  cursor: log.events.length,
@@ -630,8 +659,7 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
630
659
  signal?.throwIfAborted();
631
660
  const log = await this.readLog(id, {}, signal);
632
661
  if (log === void 0) return void 0;
633
- repairSurfaceOps(log.events);
634
- recomputeReplaceProvenance(log.events);
662
+ repairReadView(log.events);
635
663
  const inheritedEventCount = Math.min(log.inheritedEventCount, log.events.length);
636
664
  return {
637
665
  meta: log.meta,
@@ -699,14 +727,29 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
699
727
  const meta = rowToMeta(row);
700
728
  const eventRows = options.fromSeq === void 0 ? await this.backend.getEventRows(id) : await this.backend.getEventRows(id, options.fromSeq);
701
729
  signal?.throwIfAborted();
702
- if (isLegacyVersion(row.fVersion)) {
730
+ if (isLegacyVersion(row.fVersion)) try {
703
731
  const converted = convertLegacyRows(row, eventRows);
704
732
  return {
705
733
  meta: converted.meta,
706
734
  inheritedEventCount: converted.inheritedEventCount,
707
735
  events: converted.events,
708
736
  incarnation: row.fIncarnation,
709
- revision: row.fRevision
737
+ revision: row.fRevision,
738
+ storedCount: eventRows.length,
739
+ migrated: true
740
+ };
741
+ } catch (error) {
742
+ this.ctx.logger.warn(`session-rdb: session "${id}" is not a single released format; adopting its stored rows as current-format data (${error instanceof Error ? error.message : String(error)})`);
743
+ const adopted = adoptLegacyRows(row, eventRows);
744
+ return {
745
+ meta: adopted.meta,
746
+ inheritedEventCount: adopted.inheritedEventCount,
747
+ events: adopted.events,
748
+ incarnation: row.fIncarnation,
749
+ revision: row.fRevision,
750
+ storedCount: eventRows.length,
751
+ migrated: false,
752
+ ...adopted.tornFrom !== void 0 ? { tornFrom: adopted.tornFrom } : {}
710
753
  };
711
754
  }
712
755
  const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
@@ -716,9 +759,35 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
716
759
  events: preserved,
717
760
  incarnation: row.fIncarnation,
718
761
  revision: row.fRevision,
762
+ storedCount: eventRows.length,
763
+ migrated: false,
719
764
  ...tornFrom !== void 0 ? { tornFrom } : {}
720
765
  };
721
766
  }
767
+ /**
768
+ * 把迁移链读出的 v2 视图整体落库(旧格式会话写打开时的一次性迁移)。
769
+ *
770
+ * 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件 seq 空间
771
+ * 与存储桥接行数不再相等;写路径以存储 head 为锚点重编号,二者不一致会让
772
+ * append 撞上已有行。这里在同一事务内删光本会话桥接行、按迁移视图重建
773
+ * (新事件行,完整信封),并更新 head 与 revision;旧事件行保留(可能被
774
+ * fork 子会话引用,孤儿由惰性 GC 处理)。
775
+ */
776
+ async rewriteMigratedLog(id, log) {
777
+ await this.backend.transaction(async (tx) => {
778
+ await tx.deleteBridgeTail(id, 0);
779
+ await tx.upsertSession({
780
+ meta: log.meta,
781
+ inheritedEventCount: SessionLogOffset(log.inheritedEventCount)
782
+ }, randomUUID());
783
+ const { headEventId, headSequence } = await appendEventTail(tx, log.meta, log.events, {
784
+ parentId: "",
785
+ nextSeq: 0
786
+ });
787
+ await tx.updateHead(id, headEventId, headSequence);
788
+ await tx.bumpRevision(id);
789
+ });
790
+ }
722
791
  async listSnapshots(signal) {
723
792
  signal?.throwIfAborted();
724
793
  await this.ready;
@@ -904,6 +973,7 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
904
973
  if (stored.meta.cwd !== session.header.cwd) throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(stored.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`);
905
974
  if (stored.inheritedEventCount !== session.inheritedEventCount) throw new Error(`session "${id}" is already persisted with a different inherited event count (id collision)`);
906
975
  assertVersion(stored.meta);
976
+ repairReadView(stored.events);
907
977
  const seed = session.snapshotEvents();
908
978
  if (!seedCoversPrefix(seed, stored.events)) throw new Error(`session "${id}" already has a persisted log on disk that does not match this live session (id collision)`);
909
979
  handle = await this.open(id, "write");
@@ -142,7 +142,9 @@ function findSurfaceRepairs(events) {
142
142
  const degradeToAppend = /* @__PURE__ */ new Set();
143
143
  const addAppendMarker = /* @__PURE__ */ new Set();
144
144
  const clearSurfaceOp = /* @__PURE__ */ new Set();
145
- for (const event of events) {
145
+ const clampEnd = /* @__PURE__ */ new Map();
146
+ for (let index = 0; index < events.length; index++) {
147
+ const event = events[index];
146
148
  const op = event.surfaceOp;
147
149
  if (op === void 0) {
148
150
  if (SURFACE_EVENT_TYPES.has(event.type)) {
@@ -165,7 +167,12 @@ function findSurfaceRepairs(events) {
165
167
  const end = replace?.["end"];
166
168
  const shapeOk = replace !== void 0 && replace["op"] === "replace" && isEventSeqLike(start) && isEventSeqLike(end);
167
169
  const startIdx = shapeOk ? nodes.indexOf(start) : -1;
168
- const endIdx = shapeOk ? nodes.indexOf(end) : -1;
170
+ let endIdx = shapeOk ? nodes.indexOf(end) : -1;
171
+ let clamped;
172
+ if (shapeOk && startIdx !== -1 && endIdx === -1) {
173
+ clamped = clampReplaceEnd(events, index, nodes, startIdx);
174
+ if (clamped !== void 0) endIdx = nodes.indexOf(clamped);
175
+ }
169
176
  const rangeOk = shapeOk && startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx;
170
177
  let rewriteOk = true;
171
178
  if (rangeOk && event.type === "tool/result") {
@@ -181,25 +188,98 @@ function findSurfaceRepairs(events) {
181
188
  nodes.push(event.seq);
182
189
  continue;
183
190
  }
191
+ if (clamped !== void 0) clampEnd.set(event.seq, clamped);
184
192
  nodes.splice(startIdx, endIdx - startIdx + 1, event.seq);
185
193
  }
186
194
  return {
187
195
  degradeToAppend,
188
196
  addAppendMarker,
189
- clearSurfaceOp
197
+ clearSurfaceOp,
198
+ clampEnd
190
199
  };
191
200
  }
201
+ /**
202
+ * 夹取一个 end 落在旧坐标空间的 replace 的结尾。
203
+ *
204
+ * 仅当 replace 紧邻一个 metering 事件且其 `shadowedSeqs` 是权威数量时成立;
205
+ * 夹取点是从 `start` 起的第 `shadowedSeqs.length` 个当前 surface 节点(不足
206
+ * 则取当前 surface 末尾)。无法夹取(start 也不在当前 surface)时返回
207
+ * `undefined`,由调用方降级为 append。
208
+ */
209
+ function clampReplaceEnd(events, index, nodes, startIdx) {
210
+ const metering = index > 0 ? events[index - 1] : void 0;
211
+ if (metering === void 0 || !METERING_EVENT_TYPES.has(metering.type)) return void 0;
212
+ const shadowedSeqs = metering.data.shadowedSeqs;
213
+ if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return void 0;
214
+ return nodes[Math.min(startIdx + shadowedSeqs.length - 1, nodes.length - 1)];
215
+ }
192
216
  function repairSurfaceOps(events) {
193
217
  const repairs = findSurfaceRepairs(events);
194
- if (repairs.degradeToAppend.size === 0 && repairs.addAppendMarker.size === 0 && repairs.clearSurfaceOp.size === 0) return;
218
+ if (repairs.degradeToAppend.size === 0 && repairs.addAppendMarker.size === 0 && repairs.clearSurfaceOp.size === 0 && repairs.clampEnd.size === 0) return;
195
219
  for (const event of events) {
196
220
  const raw = event;
221
+ const clamped = repairs.clampEnd.get(event.seq);
197
222
  if (repairs.degradeToAppend.has(event.seq)) raw.surfaceOp = "append";
223
+ else if (clamped !== void 0) raw.surfaceOp = {
224
+ op: "replace",
225
+ start: raw.surfaceOp.start,
226
+ end: clamped
227
+ };
198
228
  else if (repairs.addAppendMarker.has(event.seq)) raw.surfaceOp = "append";
199
229
  else if (repairs.clearSurfaceOp.has(event.seq)) delete raw.surfaceOp;
200
230
  }
201
231
  }
202
232
  /**
233
+ * 读取时补全 assistant 结算字段:旧写入器不落库 `stream`(v2 才把流式记录
234
+ * 嵌入消息),读回时缺失即补空数组——上游 seed 校验要求 turn/step/stream
235
+ * 三者齐备,缺失会让整个会话加载失败。
236
+ */
237
+ function repairAssistantSettlement(events) {
238
+ for (const event of events) {
239
+ if (event.type !== "assistant/message" && event.type !== "assistant/attempt") continue;
240
+ const data = event.data;
241
+ if (!Array.isArray(data["stream"])) data["stream"] = [];
242
+ }
243
+ }
244
+ /**
245
+ * 把紧邻 replace 的 metering 事件的 `shadowedRange` / `shadowedSeqs` 对齐到
246
+ * replace 最终的稠密 range。
247
+ *
248
+ * 旧写入器重编号事件后,metering 与 replace 一起落在旧坐标空间:二者数值
249
+ * 相等但与当前 surface 无关。夹取(或 range 本身已落在稠密空间而 metering
250
+ * 仍是旧值)后二者不再相等,而上游 token-meter 契约要求紧邻的 metering
251
+ * range 与 replace range 完全一致(否则报 no adjacent shadow price)。range
252
+ * 已一致时不改写——当前写入器保证一致,shadowedSeqs 的额外并发节点因此
253
+ * 原样保留。
254
+ */
255
+ function syncMeteringRanges(events) {
256
+ for (let i = 1; i < events.length; i++) {
257
+ const metering = events[i - 1];
258
+ if (!METERING_EVENT_TYPES.has(metering.type)) continue;
259
+ const op = events[i].surfaceOp;
260
+ if (typeof op !== "object" || op === null || op.op !== "replace") continue;
261
+ const { start, end } = op;
262
+ const data = metering.data;
263
+ if (data.shadowedRange?.start === start && data.shadowedRange.end === end) continue;
264
+ data.shadowedRange = {
265
+ start,
266
+ end
267
+ };
268
+ data.shadowedSeqs = events.filter((candidate) => candidate.seq >= start && candidate.seq <= end && SURFACE_EVENT_TYPES.has(candidate.type)).map((candidate) => candidate.seq);
269
+ }
270
+ }
271
+ /**
272
+ * 读取视图修复总入口:结算字段补全 → surface 语义修复 → metering 对齐 →
273
+ * provenance 重算 → 孤儿 inbox splice 改写。全部只作用于内存视图,不落库。
274
+ */
275
+ function repairReadView(events) {
276
+ repairAssistantSettlement(events);
277
+ repairSurfaceOps(events);
278
+ syncMeteringRanges(events);
279
+ recomputeReplaceProvenance(events);
280
+ repairOrphanInboxSplices(events);
281
+ }
282
+ /**
203
283
  * 定位无法从空状态增量重放的 agent/inbox/spliced 事件(孤儿操作)。
204
284
  *
205
285
  * 上游 Inbox 每次构造都从会话起点重放全部 inbox splice,失败即拒绝整个
@@ -311,4 +391,4 @@ function toJsonlArtifact(meta, inheritedEventCount, events) {
311
391
  return lines.join("\n");
312
392
  }
313
393
  //#endregion
314
- export { repairSurfaceOps as a, scanRows as c, toJsonlArtifact as d, repairOrphanInboxSplices as i, sessionConflictRow as l, orphanInboxSpliceSeqs as n, rowToEvent as o, recomputeReplaceProvenance as r, rowToMeta as s, findSurfaceRepairs as t, sessionInsertRow as u };
394
+ export { repairOrphanInboxSplices as a, rowToEvent as c, sessionConflictRow as d, sessionInsertRow as f, repairAssistantSettlement as i, rowToMeta as l, toJsonlArtifact as m, orphanInboxSpliceSeqs as n, repairReadView as o, syncMeteringRanges as p, recomputeReplaceProvenance as r, repairSurfaceOps as s, findSurfaceRepairs as t, scanRows as u };
@@ -1,4 +1,4 @@
1
- import { l as sessionConflictRow, u as sessionInsertRow } from "./log-DTJsxMud.mjs";
1
+ import { d as sessionConflictRow, f as sessionInsertRow } from "./log-DBFUBPhv.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { and, eq, gte, sql } from "drizzle-orm";
4
4
  import { check, index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
package/dist/storage.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as SCHEMA_VERSION, c as eventKind, d as tSchemaMeta, f as tSessionEvents, g as WriteGuard, i as EVENT_ENCODING, l as tEvents, n as openDatabase, o as SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, p as tSessions, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend, u as tPersistenceState } from "./sqlite-DCy4VTD8.mjs";
1
+ import { a as SCHEMA_VERSION, c as eventKind, d as tSchemaMeta, f as tSessionEvents, g as WriteGuard, i as EVENT_ENCODING, l as tEvents, n as openDatabase, o as SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, p as tSessions, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend, u as tPersistenceState } from "./sqlite-CnWwGToZ.mjs";
2
2
  export { DEFAULT_BUSY_TIMEOUT_MS, EVENT_ENCODING, SCHEMA_VERSION, SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, SqliteBackend, WriteGuard, eventDimensions, eventKind, openDatabase, tEvents, tPersistenceState, tSchemaMeta, tSessionEvents, tSessions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.16-alpha.0",
3
+ "version": "0.0.16-alpha.1",
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,7 +35,7 @@
35
35
  "./cordis.patch.yml": "./cordis.patch.yml"
36
36
  },
37
37
  "dependencies": {
38
- "@morlay/session-branch": "^0.0.4-alpha.0",
38
+ "@morlay/session-branch": "^0.0.4-alpha.1",
39
39
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
40
40
  "fflate": "^0.8.2",
41
41
  "pg": "^8.23.0"
package/src/index.ts CHANGED
@@ -39,14 +39,7 @@ import {
39
39
  } from "@deepseek-ai/dsh-session";
40
40
  import { type Backend, type BackendTx, type EventInsert } from "./backend.ts";
41
41
  import { WriteGuard } from "./write-guard.ts";
42
- import {
43
- recomputeReplaceProvenance,
44
- repairOrphanInboxSplices,
45
- repairSurfaceOps,
46
- rowToMeta,
47
- scanRows,
48
- toJsonlArtifact,
49
- } from "./log.ts";
42
+ import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
50
43
  import {
51
44
  DEFAULT_BUSY_TIMEOUT_MS,
52
45
  eventDimensions,
@@ -57,7 +50,7 @@ import { SqliteBackend } from "./sqlite.ts";
57
50
  import { PostgresBackend } from "./postgres.ts";
58
51
  import { SessionBranchRdb } from "./branch.ts";
59
52
  import { registerSessionImport } from "./import.ts";
60
- import { convertLegacyRows, isLegacyVersion } from "./legacy.ts";
53
+ import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
61
54
 
62
55
  export { SCHEMA_VERSION } from "./schema.ts";
63
56
  export { SessionBranchRdb, SessionBranchRdbProvider, locateTurnEnd } from "./branch.ts";
@@ -278,11 +271,10 @@ class RdbSessionHandle implements SessionHandle {
278
271
  }
279
272
  throw new SessionPersistenceNotFoundError(this.id);
280
273
  }
281
- // 读取时修复(视图只读,不落库):surface 替换 provenance 重计算、孤儿
282
- // inbox splice 改写、非法 surface 替换降级。
283
- repairSurfaceOps(log.events);
284
- recomputeReplaceProvenance(log.events);
285
- repairOrphanInboxSplices(log.events);
274
+ // 读取时修复(视图只读,不落库):结算字段补全、非法 surface 替换降级
275
+ // 或按 metering 数量夹取、metering range 对齐、provenance 重算、孤儿
276
+ // inbox splice 改写。
277
+ repairReadView(log.events);
286
278
  return { eventState: "detached", events: log.events.slice(offset, offset + length) };
287
279
  }
288
280
 
@@ -593,6 +585,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
593
585
  const log = await this.readLog(id, {}, options?.signal);
594
586
  if (log === undefined) throw new SessionPersistenceNotFoundError(id);
595
587
  validateStoredEvents(log.meta, log.events);
588
+ // 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件坐标与
589
+ // 存储桥接行数不再相等——写打开时把迁移视图整体落库,使读写同坐标;
590
+ // 否则 append 按存储 head 重编号会撞上已有行或写坏 log。
591
+ if (log.migrated && log.events.length !== log.storedCount) {
592
+ await this.rewriteMigratedLog(id, log);
593
+ }
596
594
  // 确认 head:本实例已读该会话,后续 append 的并发校验以此为基准。
597
595
  this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
598
596
  return this.tracker.adopt(
@@ -669,8 +667,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
669
667
  signal?.throwIfAborted();
670
668
  const log = await this.readLog(id, {}, signal);
671
669
  if (log === undefined) return undefined;
672
- repairSurfaceOps(log.events);
673
- recomputeReplaceProvenance(log.events);
670
+ repairReadView(log.events);
674
671
  const inheritedEventCount = Math.min(log.inheritedEventCount, log.events.length);
675
672
  return {
676
673
  meta: log.meta,
@@ -762,6 +759,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
762
759
  incarnation: string;
763
760
 
764
761
  revision: number;
762
+
763
+ /** 存储桥接行数(迁移链可能生成/合并事件,与 `events.length` 不同)。 */
764
+ storedCount: number;
765
+
766
+ /** 是否经上游迁移链转换(v0/v1 → 当前格式)。 */
767
+ migrated: boolean;
765
768
  }
766
769
  | undefined
767
770
  > {
@@ -778,15 +781,36 @@ export class SessionPersistenceRdb extends SessionPersistence {
778
781
  signal?.throwIfAborted();
779
782
  // 旧格式(v0/v1)历史数据:行重建为物理记录,经上游迁移链转 v2 逻辑事件。
780
783
  // 迁移链自带 seq gap / torn tail 校验(strict recovery),无需 scanRows。
784
+ // 混合世代 log(旧写入器跨上游版本追加)不是任何单一已发布格式,迁移链
785
+ // 必然拒绝——回退为当前格式视图(header 版本归一 + 读取视图修复)。
781
786
  if (isLegacyVersion(row.fVersion)) {
782
- const converted = convertLegacyRows(row, eventRows);
783
- return {
784
- meta: converted.meta,
785
- inheritedEventCount: converted.inheritedEventCount,
786
- events: converted.events,
787
- incarnation: row.fIncarnation,
788
- revision: row.fRevision,
789
- };
787
+ try {
788
+ const converted = convertLegacyRows(row, eventRows);
789
+ return {
790
+ meta: converted.meta,
791
+ inheritedEventCount: converted.inheritedEventCount,
792
+ events: converted.events,
793
+ incarnation: row.fIncarnation,
794
+ revision: row.fRevision,
795
+ storedCount: eventRows.length,
796
+ migrated: true,
797
+ };
798
+ } catch (error: unknown) {
799
+ this.ctx.logger.warn(
800
+ `session-rdb: session "${id}" is not a single released format; adopting its stored rows as current-format data (${error instanceof Error ? error.message : String(error)})`,
801
+ );
802
+ const adopted = adoptLegacyRows(row, eventRows);
803
+ return {
804
+ meta: adopted.meta,
805
+ inheritedEventCount: adopted.inheritedEventCount,
806
+ events: adopted.events,
807
+ incarnation: row.fIncarnation,
808
+ revision: row.fRevision,
809
+ storedCount: eventRows.length,
810
+ migrated: false,
811
+ ...(adopted.tornFrom !== undefined ? { tornFrom: adopted.tornFrom } : {}),
812
+ };
813
+ }
790
814
  }
791
815
  const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
792
816
  return {
@@ -795,10 +819,40 @@ export class SessionPersistenceRdb extends SessionPersistence {
795
819
  events: preserved,
796
820
  incarnation: row.fIncarnation,
797
821
  revision: row.fRevision,
822
+ storedCount: eventRows.length,
823
+ migrated: false,
798
824
  ...(tornFrom !== undefined ? { tornFrom } : {}),
799
825
  };
800
826
  }
801
827
 
828
+ /**
829
+ * 把迁移链读出的 v2 视图整体落库(旧格式会话写打开时的一次性迁移)。
830
+ *
831
+ * 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件 seq 空间
832
+ * 与存储桥接行数不再相等;写路径以存储 head 为锚点重编号,二者不一致会让
833
+ * append 撞上已有行。这里在同一事务内删光本会话桥接行、按迁移视图重建
834
+ * (新事件行,完整信封),并更新 head 与 revision;旧事件行保留(可能被
835
+ * fork 子会话引用,孤儿由惰性 GC 处理)。
836
+ */
837
+ private async rewriteMigratedLog(
838
+ id: SessionId,
839
+ log: { meta: SessionHeader; inheritedEventCount: number; events: SessionEvent[] },
840
+ ): Promise<void> {
841
+ await this.backend.transaction(async (tx) => {
842
+ await tx.deleteBridgeTail(id, 0);
843
+ await tx.upsertSession(
844
+ { meta: log.meta, inheritedEventCount: SessionLogOffset(log.inheritedEventCount) },
845
+ randomUUID(),
846
+ );
847
+ const { headEventId, headSequence } = await appendEventTail(tx, log.meta, log.events, {
848
+ parentId: "",
849
+ nextSeq: 0,
850
+ });
851
+ await tx.updateHead(id, headEventId, headSequence);
852
+ await tx.bumpRevision(id);
853
+ });
854
+ }
855
+
802
856
  async listSnapshots(
803
857
  signal?: AbortSignal,
804
858
  ): Promise<Array<SessionPersistenceSnapshot & { inheritedEventCount: number }>> {
@@ -1068,6 +1122,9 @@ export class SessionPersistenceRdb extends SessionPersistence {
1068
1122
  );
1069
1123
  }
1070
1124
  assertVersion(stored.meta);
1125
+ // adopt 比较必须与读取视图同源:live seed 来自修复后的读取视图(补
1126
+ // stream、surface 修复),未修复的存储视图会把修复差异误判为 id 冲突。
1127
+ repairReadView(stored.events);
1071
1128
  const seed = session.snapshotEvents();
1072
1129
  if (!seedCoversPrefix(seed, stored.events)) {
1073
1130
  throw new Error(
package/src/legacy.ts CHANGED
@@ -3,9 +3,15 @@
3
3
  // delta(assistant/chunk)落盘被过滤——v1→v2 迁移对无 chunk 引用的
4
4
  // assistant/message 走「无 pending 直接 emit」路径,生成 stream: [] 的 v2 事件。
5
5
 
6
- import type { SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session";
6
+ import {
7
+ SESSION_FORMAT_VERSION,
8
+ type SessionEvent,
9
+ type SessionHeader,
10
+ type SessionId,
11
+ } from "@deepseek-ai/dsh-session";
7
12
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
8
13
  import type { EventRow, SessionRow } from "./backend.ts";
14
+ import { rowToMeta, scanRows } from "./log.ts";
9
15
 
10
16
  /** 重建 v0/v1 物理 header 记录(RDB 行 → 物理 JSON 对象)。 */
11
17
  function physicalHeader(row: SessionRow): Record<string, unknown> {
@@ -64,4 +70,41 @@ export function isLegacyVersion(version: number): boolean {
64
70
  return version < 2;
65
71
  }
66
72
 
73
+ /**
74
+ * 迁移链拒绝后,把旧格式行直接当**当前格式**事件视图采用。
75
+ *
76
+ * 旧写入器只在建会话时落一次 header version,之后跟随上游演进继续追加
77
+ * 事件——同一个 log 因此可能是混合世代(v0 时代的行 + 新版本字段),不是
78
+ * 任何单一已发布格式,严格迁移链必然拒绝。这些行的数据形状(消息包装、
79
+ * 类型名)已经是当前格式,只需 header 版本归一到当前、按稠密 seq 读取;
80
+ * 事件数据的补全与 surface 修复由读取视图修复(repairReadView)负责。
81
+ *
82
+ * 调用方须在此之后执行 `validateStoredEvents`:真正属于旧格式的类型
83
+ * (`assistant/chunk` / `compact/*` 等)仍会被 fail-loud 拒绝。
84
+ *
85
+ * @param row - 会话行(f_version < 2)。
86
+ * @param eventRows - 该会话全部桥接行(按 f_sequence 升序)。
87
+ * @returns 当前格式 header(version 归一到 SESSION_FORMAT_VERSION)、收缩后的
88
+ * 继承前缀长度、稠密事件与可选 torn tail 起点。
89
+ * @throws scanRows 的已提交区损坏错误(seq gap / 不可解析行)。
90
+ */
91
+ export function adoptLegacyRows(
92
+ row: SessionRow,
93
+ eventRows: readonly EventRow[],
94
+ ): {
95
+ meta: SessionHeader;
96
+ inheritedEventCount: number;
97
+ events: SessionEvent[];
98
+ tornFrom?: number;
99
+ } {
100
+ const { preserved, tornFrom } = scanRows(eventRows, 0);
101
+ return {
102
+ meta: { ...rowToMeta(row), version: SESSION_FORMAT_VERSION },
103
+ // 继承前缀不得超过事件数(历史 rewind 未收缩的损坏样式),上游 load 判损坏。
104
+ inheritedEventCount: Math.min(row.fSeedLength ?? 0, preserved.length),
105
+ events: preserved,
106
+ ...(tornFrom !== undefined ? { tornFrom } : {}),
107
+ };
108
+ }
109
+
67
110
  export type { SessionId };
package/src/log.ts CHANGED
@@ -211,12 +211,15 @@ export function findSurfaceRepairs(events: readonly SessionEvent[]): {
211
211
  degradeToAppend: Set<number>;
212
212
  addAppendMarker: Set<number>;
213
213
  clearSurfaceOp: Set<number>;
214
+ clampEnd: Map<number, number>;
214
215
  } {
215
216
  const nodes: number[] = [];
216
217
  const degradeToAppend = new Set<number>();
217
218
  const addAppendMarker = new Set<number>();
218
219
  const clearSurfaceOp = new Set<number>();
219
- for (const event of events) {
220
+ const clampEnd = new Map<number, number>();
221
+ for (let index = 0; index < events.length; index++) {
222
+ const event = events[index]!;
220
223
  const raw = event as SessionEvent & { surfaceOp?: unknown };
221
224
  const op = raw.surfaceOp;
222
225
  if (op === undefined) {
@@ -247,7 +250,16 @@ export function findSurfaceRepairs(events: readonly SessionEvent[]): {
247
250
  isEventSeqLike(start) &&
248
251
  isEventSeqLike(end);
249
252
  const startIdx = shapeOk ? nodes.indexOf(start as number) : -1;
250
- const endIdx = shapeOk ? nodes.indexOf(end as number) : -1;
253
+ let endIdx = shapeOk ? nodes.indexOf(end as number) : -1;
254
+ // 旧写入器重编号事件后,存储的 range 端点可能落在旧坐标空间:end 不在
255
+ // 当前 surface 中,但紧邻 metering 事件的 shadowedSeqs 仍给出被遮蔽
256
+ // 节点数量。按数量从 start 起夹取到当前 surface 的同长区间,保住压缩
257
+ // 语义(否则只能降级 append,被压缩历史全部回到派生历史)。
258
+ let clamped: number | undefined;
259
+ if (shapeOk && startIdx !== -1 && endIdx === -1) {
260
+ clamped = clampReplaceEnd(events, index, nodes, startIdx);
261
+ if (clamped !== undefined) endIdx = nodes.indexOf(clamped);
262
+ }
251
263
  const rangeOk = shapeOk && startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx;
252
264
  let rewriteOk = true;
253
265
  if (rangeOk && event.type === "tool/result") {
@@ -265,9 +277,31 @@ export function findSurfaceRepairs(events: readonly SessionEvent[]): {
265
277
  nodes.push(event.seq);
266
278
  continue;
267
279
  }
280
+ if (clamped !== undefined) clampEnd.set(event.seq, clamped);
268
281
  nodes.splice(startIdx, endIdx - startIdx + 1, event.seq);
269
282
  }
270
- return { degradeToAppend, addAppendMarker, clearSurfaceOp };
283
+ return { degradeToAppend, addAppendMarker, clearSurfaceOp, clampEnd };
284
+ }
285
+
286
+ /**
287
+ * 夹取一个 end 落在旧坐标空间的 replace 的结尾。
288
+ *
289
+ * 仅当 replace 紧邻一个 metering 事件且其 `shadowedSeqs` 是权威数量时成立;
290
+ * 夹取点是从 `start` 起的第 `shadowedSeqs.length` 个当前 surface 节点(不足
291
+ * 则取当前 surface 末尾)。无法夹取(start 也不在当前 surface)时返回
292
+ * `undefined`,由调用方降级为 append。
293
+ */
294
+ function clampReplaceEnd(
295
+ events: readonly SessionEvent[],
296
+ index: number,
297
+ nodes: readonly number[],
298
+ startIdx: number,
299
+ ): number | undefined {
300
+ const metering = index > 0 ? events[index - 1] : undefined;
301
+ if (metering === undefined || !METERING_EVENT_TYPES.has(metering.type)) return undefined;
302
+ const shadowedSeqs = (metering.data as unknown as { shadowedSeqs?: unknown }).shadowedSeqs;
303
+ if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return undefined;
304
+ return nodes[Math.min(startIdx + shadowedSeqs.length - 1, nodes.length - 1)];
271
305
  }
272
306
 
273
307
  export function repairSurfaceOps(events: SessionEvent[]): void {
@@ -275,14 +309,19 @@ export function repairSurfaceOps(events: SessionEvent[]): void {
275
309
  if (
276
310
  repairs.degradeToAppend.size === 0 &&
277
311
  repairs.addAppendMarker.size === 0 &&
278
- repairs.clearSurfaceOp.size === 0
312
+ repairs.clearSurfaceOp.size === 0 &&
313
+ repairs.clampEnd.size === 0
279
314
  ) {
280
315
  return;
281
316
  }
282
317
  for (const event of events) {
283
318
  const raw = event as SessionEvent & { surfaceOp?: unknown };
319
+ const clamped = repairs.clampEnd.get(event.seq);
284
320
  if (repairs.degradeToAppend.has(event.seq)) {
285
321
  raw.surfaceOp = "append";
322
+ } else if (clamped !== undefined) {
323
+ const op = raw.surfaceOp as { start: number };
324
+ raw.surfaceOp = { op: "replace", start: op.start, end: clamped };
286
325
  } else if (repairs.addAppendMarker.has(event.seq)) {
287
326
  raw.surfaceOp = "append";
288
327
  } else if (repairs.clearSurfaceOp.has(event.seq)) {
@@ -291,6 +330,65 @@ export function repairSurfaceOps(events: SessionEvent[]): void {
291
330
  }
292
331
  }
293
332
 
333
+ /**
334
+ * 读取时补全 assistant 结算字段:旧写入器不落库 `stream`(v2 才把流式记录
335
+ * 嵌入消息),读回时缺失即补空数组——上游 seed 校验要求 turn/step/stream
336
+ * 三者齐备,缺失会让整个会话加载失败。
337
+ */
338
+ export function repairAssistantSettlement(events: SessionEvent[]): void {
339
+ for (const event of events) {
340
+ if (event.type !== "assistant/message" && event.type !== "assistant/attempt") continue;
341
+ const data = event.data as unknown as Record<string, unknown>;
342
+ if (!Array.isArray(data["stream"])) data["stream"] = [];
343
+ }
344
+ }
345
+
346
+ /**
347
+ * 把紧邻 replace 的 metering 事件的 `shadowedRange` / `shadowedSeqs` 对齐到
348
+ * replace 最终的稠密 range。
349
+ *
350
+ * 旧写入器重编号事件后,metering 与 replace 一起落在旧坐标空间:二者数值
351
+ * 相等但与当前 surface 无关。夹取(或 range 本身已落在稠密空间而 metering
352
+ * 仍是旧值)后二者不再相等,而上游 token-meter 契约要求紧邻的 metering
353
+ * range 与 replace range 完全一致(否则报 no adjacent shadow price)。range
354
+ * 已一致时不改写——当前写入器保证一致,shadowedSeqs 的额外并发节点因此
355
+ * 原样保留。
356
+ */
357
+ export function syncMeteringRanges(events: SessionEvent[]): void {
358
+ for (let i = 1; i < events.length; i++) {
359
+ const metering = events[i - 1]!;
360
+ if (!METERING_EVENT_TYPES.has(metering.type)) continue;
361
+ const event = events[i]!;
362
+ const op = (event as SessionEvent & { surfaceOp?: unknown }).surfaceOp;
363
+ if (typeof op !== "object" || op === null || (op as { op?: string }).op !== "replace") continue;
364
+ const { start, end } = op as { start: number; end: number };
365
+ const data = metering.data as unknown as {
366
+ shadowedRange?: { start: number; end: number };
367
+ shadowedSeqs?: number[];
368
+ };
369
+ if (data.shadowedRange?.start === start && data.shadowedRange.end === end) continue;
370
+ data.shadowedRange = { start, end };
371
+ data.shadowedSeqs = events
372
+ .filter(
373
+ (candidate) =>
374
+ candidate.seq >= start && candidate.seq <= end && SURFACE_EVENT_TYPES.has(candidate.type),
375
+ )
376
+ .map((candidate) => candidate.seq);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * 读取视图修复总入口:结算字段补全 → surface 语义修复 → metering 对齐 →
382
+ * provenance 重算 → 孤儿 inbox splice 改写。全部只作用于内存视图,不落库。
383
+ */
384
+ export function repairReadView(events: SessionEvent[]): void {
385
+ repairAssistantSettlement(events);
386
+ repairSurfaceOps(events);
387
+ syncMeteringRanges(events);
388
+ recomputeReplaceProvenance(events);
389
+ repairOrphanInboxSplices(events);
390
+ }
391
+
294
392
  interface InboxSpliceLike {
295
393
  target?: unknown;
296
394
  start?: unknown;