@morlay/session-rdb 0.0.12 → 0.0.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
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",
@@ -25,12 +25,16 @@
25
25
  "type": "module",
26
26
  "exports": {
27
27
  ".": "./dist/index.mjs",
28
+ "./artifact": "./dist/artifact.mjs",
29
+ "./import": "./dist/import.mjs",
28
30
  "./invariant": "./dist/invariant.mjs",
31
+ "./storage": "./dist/storage.mjs",
32
+ "./testing": "./dist/testing.mjs",
29
33
  "./package.json": "./package.json",
30
34
  "./cordis.patch.yml": "./cordis.patch.yml"
31
35
  },
32
36
  "dependencies": {
33
- "@morlay/session-branch": "^0.0.2",
37
+ "@morlay/session-branch": "^0.0.3",
34
38
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
35
39
  "fflate": "^0.8.2",
36
40
  "pg": "^8.23.0"
@@ -55,6 +59,19 @@
55
59
  "patch": "./cordis.patch.yml"
56
60
  }
57
61
  },
62
+ "inlinedDependencies": {
63
+ "@jridgewell/sourcemap-codec": "1.6.0",
64
+ "@vitest/expect": "4.1.11",
65
+ "@vitest/pretty-format": "4.1.11",
66
+ "@vitest/runner": "4.1.11",
67
+ "@vitest/snapshot": "4.1.11",
68
+ "@vitest/spy": "4.1.11",
69
+ "@vitest/utils": "4.1.11",
70
+ "chai": "6.2.2",
71
+ "magic-string": "0.30.21",
72
+ "tinyrainbow": "3.1.1",
73
+ "vitest": "4.1.11"
74
+ },
58
75
  "scripts": {
59
76
  "build": "pnpm exec tsdown"
60
77
  }
@@ -0,0 +1,4 @@
1
+ // 日志 / artifact 格式层:会话日志行处理(scanRows / rowToEvent / 修复)与
2
+ // 导入导出 artifact 解析(jsonl / zip)。文件物理分开,经本入口聚合导出。
3
+ export * from "./log.ts";
4
+ export * from "./import.ts";
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { WriteGuard } from "./write-guard.ts";
27
27
  import {
28
28
  buildSeqMap,
29
29
  recomputeReplaceProvenance,
30
+ repairOrphanInboxSplices,
30
31
  repairSurfaceOps,
31
32
  rowToMeta,
32
33
  scanRows,
@@ -261,6 +262,9 @@ export class SessionPersistenceRdb
261
262
  this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
262
263
  // replace 的 provenance 读取时重计算(sourceEventSeqs 不落库)。
263
264
  recomputeReplaceProvenance(log.events);
265
+ // 孤儿 inbox splice(引用已被截断排队消息的插入/消费)会使上游 Inbox
266
+ // 增量重放失败、整个会话不可 resume——读取时改写为 no-op 修复。
267
+ repairOrphanInboxSplices(log.events);
264
268
  return {
265
269
  meta: log.meta,
266
270
  inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
package/src/log.ts CHANGED
@@ -90,6 +90,13 @@ export function remapShadowedRange(
90
90
  return { start: remap(range.start), end: remap(range.end) };
91
91
  }
92
92
 
93
+ export function remapShadowedSeqs(
94
+ seqs: readonly number[],
95
+ remap: (seq: number) => number,
96
+ ): number[] {
97
+ return seqs.map(remap);
98
+ }
99
+
93
100
  export function rowToEvent(row: EventRow, seqMap: ReadonlyMap<number, number>): SessionEvent {
94
101
  const remap = (seq: number): number => seqMap.get(seq) ?? seq;
95
102
  const surfaceOp =
@@ -97,12 +104,21 @@ export function rowToEvent(row: EventRow, seqMap: ReadonlyMap<number, number>):
97
104
  ? remapSurfaceOp(JSON.parse(row.fSurfaceOp) as SurfaceOp, remap)
98
105
  : undefined;
99
106
  const data = JSON.parse(row.fData) as SessionEvent["data"];
100
- // compaction 事件的 shadowedRange 是插件合并字段,经结构化视图重映射。
107
+ // compaction 事件的 shadowedRange / shadowedSeqs 是插件合并字段,经结构化
108
+ // 视图重映射到稠密坐标。shadowedSeqs 是权威的被遮蔽节点列表(range 只是
109
+ // 首尾边界对,压缩竞态下可能漏掉并发落地的节点),replace 的 provenance
110
+ // 重计算依赖它,必须与 range 同空间。
101
111
  if (row.fType === "compaction/summary" || row.fType === "compaction/prune") {
102
- const metering = data as unknown as { shadowedRange?: { start: number; end: number } };
112
+ const metering = data as unknown as {
113
+ shadowedRange?: { start: number; end: number };
114
+ shadowedSeqs?: number[];
115
+ };
103
116
  if (metering.shadowedRange !== undefined) {
104
117
  metering.shadowedRange = remapShadowedRange(metering.shadowedRange, remap);
105
118
  }
119
+ if (metering.shadowedSeqs !== undefined) {
120
+ metering.shadowedSeqs = remapShadowedSeqs(metering.shadowedSeqs, remap);
121
+ }
106
122
  }
107
123
  return {
108
124
  type: row.fType as SessionEvent["type"],
@@ -125,14 +141,38 @@ export function buildSeqMap(
125
141
 
126
142
  const SURFACE_EVENT_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);
127
143
 
144
+ const METERING_EVENT_TYPES = new Set(["compaction/summary", "compaction/prune"]);
145
+
146
+ /**
147
+ * 读取时重计算 replace 的 sourceEventSeqs(sourceEventSeqs 不落库)。
148
+ *
149
+ * 优先采用紧邻 metering 事件(compaction/summary | compaction/prune)的
150
+ * shadowedSeqs:它是压缩事务落库的**权威被遮蔽节点列表**(range 只是首尾
151
+ * 边界对,压缩竞态下可能漏掉并发落地的节点——range 数值扫描会漏掉这些
152
+ * 节点,使上游 assertProvenance 报 missing)。shadowedSeqs 已由 rowToEvent
153
+ * 重映射到稠密坐标,与 replace 的 surfaceOp range 同空间。
154
+ *
155
+ * 无紧邻 metering 事件时回退到 range 数值扫描(历史数据 / 非压缩 replace,
156
+ * 如 tool-result pruner 的旧样式),保证既有行为不变。
157
+ */
128
158
  export function recomputeReplaceProvenance(events: SessionEvent[]): void {
129
- for (const event of events) {
159
+ for (let i = 0; i < events.length; i++) {
160
+ const event = events[i]!;
130
161
  const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: number[] };
131
162
  const op = raw.surfaceOp;
132
163
  if (typeof op !== "object" || op === null || (op as { op?: string }).op !== "replace") {
133
164
  continue;
134
165
  }
135
166
  const { start, end } = op as { start: number; end: number };
167
+ const metering = i > 0 ? events[i - 1] : undefined;
168
+ const meteringData =
169
+ metering !== undefined && METERING_EVENT_TYPES.has(metering.type)
170
+ ? (metering.data as unknown as { shadowedSeqs?: number[] })
171
+ : undefined;
172
+ if (meteringData?.shadowedSeqs !== undefined) {
173
+ raw.sourceEventSeqs = meteringData.shadowedSeqs;
174
+ continue;
175
+ }
136
176
  const refs: number[] = [];
137
177
  for (const candidate of events) {
138
178
  if (
@@ -283,6 +323,92 @@ export function repairSurfaceOps(events: SessionEvent[]): void {
283
323
  }
284
324
  }
285
325
 
326
+ interface InboxSpliceLike {
327
+ target?: unknown;
328
+ start?: unknown;
329
+ removedCount?: unknown;
330
+ inserted?: Array<{ id?: unknown }>;
331
+ }
332
+
333
+ /**
334
+ * 定位无法从空状态增量重放的 agent/inbox/spliced 事件(孤儿操作)。
335
+ *
336
+ * 上游 Inbox 每次构造都从会话起点重放全部 inbox splice,失败即拒绝整个
337
+ * 会话(resume / 编辑重放不可用)。rewind 截断历史轮次后若残留 splice 引用
338
+ * 已被截断的排队消息(插入被删、消费保留,或反之),重放时越界或重复——
339
+ * 无法独立重放。
340
+ */
341
+ export function orphanInboxSpliceSeqs(events: readonly SessionEvent[]): Set<number> {
342
+ const inbox: Record<string, Array<{ id: string }>> = { "next-turn": [], "next-step": [] };
343
+ const orphan = new Set<number>();
344
+ for (const raw of events) {
345
+ // agent/inbox/spliced 属 dsh-agent 类型扩展,不在本包 SessionEventMap——
346
+ // 用宽类型 duck-type 读取,避免判别联合窄化到 never。
347
+ const event = raw as unknown as { type: string; seq: number; data: InboxSpliceLike };
348
+ if (event.type !== "agent/inbox/spliced") continue;
349
+ const { target, start, removedCount, inserted } = event.data;
350
+ const list = typeof target === "string" ? inbox[target] : undefined;
351
+ if (list === undefined) {
352
+ orphan.add(event.seq);
353
+ continue;
354
+ }
355
+ const removed = removedCount ?? 0;
356
+ const parsedInserted = (inserted ?? []).map((m) => ({
357
+ id: typeof m.id === "string" ? m.id : "",
358
+ }));
359
+ if (
360
+ !Number.isSafeInteger(start as number) ||
361
+ (start as number) < 0 ||
362
+ (start as number) > list.length ||
363
+ !Number.isSafeInteger(removed as number) ||
364
+ (removed as number) < 0 ||
365
+ (start as number) + (removed as number) > list.length
366
+ ) {
367
+ orphan.add(event.seq);
368
+ continue;
369
+ }
370
+ const candidate = [
371
+ ...list.slice(0, start as number),
372
+ ...parsedInserted,
373
+ ...list.slice((start as number) + (removed as number)),
374
+ ];
375
+ const other = (target === "next-turn" ? inbox["next-step"] : inbox["next-turn"]) ?? [];
376
+ const seen = new Set<string>();
377
+ let dup = false;
378
+ for (const m of [...candidate, ...other]) {
379
+ if (m.id === "") continue;
380
+ if (seen.has(m.id)) {
381
+ dup = true;
382
+ break;
383
+ }
384
+ seen.add(m.id);
385
+ }
386
+ if (dup) {
387
+ orphan.add(event.seq);
388
+ continue;
389
+ }
390
+ list.splice(start as number, removed as number, ...parsedInserted);
391
+ }
392
+ return orphan;
393
+ }
394
+
395
+ /** 内存修复:把孤儿 inbox splice 改写为 no-op(调用方负责持久化)。 */
396
+ export function repairOrphanInboxSplices(events: SessionEvent[]): void {
397
+ const orphan = orphanInboxSpliceSeqs(events);
398
+ if (orphan.size === 0) return;
399
+ for (const event of events) {
400
+ if (!orphan.has(event.seq)) continue;
401
+ const data = event.data as unknown as InboxSpliceLike;
402
+ const target = data.target;
403
+ (event as { data: unknown }).data = {
404
+ ...(typeof target === "string" ? { target } : { target: "next-turn" }),
405
+ start: 0,
406
+ removedCount: 0,
407
+ inserted: [],
408
+ };
409
+ }
410
+ }
411
+
286
412
  export function scanRows(
287
413
  rows: readonly EventRow[],
288
414
  base = 0,
package/src/storage.ts ADDED
@@ -0,0 +1,5 @@
1
+ // 存储引擎层:schema 常量/表定义、sqlite 后端(openDatabase / SqliteBackend)与
2
+ // 并发写保护(WriteGuard)。文件物理分开,经本入口聚合导出。
3
+ export * from "./schema.ts";
4
+ export * from "./sqlite.ts";
5
+ export * from "./write-guard.ts";