@crazx/dsh-session-persistence 0.1.2-alpha.3.zw.2 → 0.1.2-alpha.5.zw.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.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
5
- README.md: ae44ec5b7603f4b36b0f32f040cf9d685b7c08e9
6
- README.zh.md: 44112d6aad80b39e169561cce509e2012722f5e7
5
+ README.md: 44457cad08e386de686aabe1a31be27673a59c72
6
+ README.zh.md: 33193b2982e5fd45496abd998a0b38ffdfb71170
package/README.md CHANGED
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  ## Summary
11
11
 
12
- `dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through the backend-neutral `ctx.sessionPersistence` service. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type and non-replayable metadata (format version, working directory, lineage, seed boundary) travels separately as `SessionHeader`. A backend owns its storage, while the service owns append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. The shipped JSONL provider implements this service with one artifact per Session; third-party providers may implement the same contract without changing the loop or model.
12
+ `dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through the backend-neutral `ctx.sessionPersistence` service. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type. `SessionHeader.isSeeded` makes lineage visible to lightweight listing, while the exact `inheritedEventCount` accompanies every body-bearing storage read and prepared Session. A backend owns its storage, while the service owns append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. The shipped JSONL provider implements this service with one artifact per Session; third-party providers may implement the same contract without changing the loop or model.
13
13
 
14
14
  ## Table of Contents
15
15
 
@@ -36,18 +36,18 @@ The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend. It s
36
36
  With a backend mounted, you can store a session's events durably, reload the stored log, and list what is stored:
37
37
 
38
38
  ```text
39
- await ctx.sessionPersistence.create(meta) // register a session
39
+ await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded
40
40
  await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session
41
41
  await ctx.sessionPersistence.append(id, events) // durably persist a batch
42
- const { meta, events } = await ctx.sessionPersistence.load(id) // reload on resume
42
+ const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id)
43
43
  const headers = await ctx.sessionPersistence.list() // every stored session
44
44
  ```
45
45
 
46
- `append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create` remains lazy; a lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `load` returns an immutable balanced log and commits any needed crash recovery; `inspect` reads the same view without committing recovery. Consumers that resume from a watermark can read only the events at or past a sequence number, and a session's artifact location (`locate`) resolves without filesystem I/O.
46
+ `append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create(meta, inheritedEventCount)` remains lazy; `meta.isSeeded: true` requires the sibling exact cut, while unseeded metadata may omit it and rejects a nonzero value. The first materializing batch for a seeded session must reach the complete inherited prefix, so storage never exposes metadata whose cut exceeds its log. A lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `load` returns an immutable balanced log and commits any needed crash recovery; `inspect` reads the same complete view without committing recovery. `readFrom` accepts a `SessionLogOffset` and returns a detached `SessionEventSuffix` carrying that `fromSeq`, the unchanged inherited cut, and only stored events at or after the cut. A session's artifact location (`locate`) resolves without filesystem I/O.
47
47
 
48
48
  ### Resuming and crash recovery
49
49
 
50
- Resume is `load` plus session preparation: the stored log comes back with its header lineage intact, so a resumed agent sees the same history and composition. A session that crashed mid-turn reloads with its interrupted final turn preserved and balanced: `load` appends synthetic `tool/result` and `turn/end {interrupted}` closers for unanswered calls instead of dropping the events — a single turn can be large, and those events were durably written before the crash. Only a never-fully-written torn tail fragment is discarded.
50
+ Resume is `load` plus session preparation: the stored log comes back with its header lineage and exact inherited cut intact, so ownership checks do not infer the cut from a marker or the full restore length. A session that crashed mid-turn reloads with its interrupted final turn preserved and balanced: `load` appends synthetic `tool/result` and `turn/end {interrupted}` closers for unanswered calls instead of dropping the events — a single turn can be large, and those events were durably written before the crash. Only a never-fully-written torn tail fragment is discarded.
51
51
 
52
52
  ### Failures and recovery
53
53
 
@@ -83,7 +83,7 @@ The package is the Service Definition of a capability seam with two halves. The
83
83
  | [`src/write-behind.ts`](src/write-behind.ts) | The per-session bounded write controller and flush barrier |
84
84
  | [`src/preparations.ts`](src/preparations.ts) | Bounded retention of unpublished Session preparations for resume reuse |
85
85
  | [`src/revision.ts`](src/revision.ts) | The branded opaque revision token |
86
- | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the coordinator asserts stored/live identity and cwd) |
86
+ | | No runtime invariant companion is published; persistence correctness requires backend round-trip and crash-tail tests; this package exposes no continuously observable in-process relation. |
87
87
 
88
88
  ### The write path at a glance
89
89
 
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@ kind: "package-reference"
9
9
 
10
10
  ## 概述
11
11
 
12
- `dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型——不可回放的元数据(格式版本、工作目录、血缘、种子边界)作为 `SessionHeader` 单独传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。
12
+ `dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型。`SessionHeader.isSeeded` 让轻量列表可见血缘,而精确的 `inheritedEventCount` 随每次带正文的存储读取与 prepared Session 一同传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。
13
13
 
14
14
  ## 目录
15
15
 
@@ -36,18 +36,18 @@ seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端
36
36
  挂载后端后,你可以持久存储会话事件、重新加载已存储日志并列出已存储内容:
37
37
 
38
38
  ```text
39
- await ctx.sessionPersistence.create(meta) // register a session
39
+ await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded
40
40
  await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session
41
41
  await ctx.sessionPersistence.append(id, events) // durably persist a batch
42
- const { meta, events } = await ctx.sessionPersistence.load(id) // reload on resume
42
+ const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id)
43
43
  const headers = await ctx.sessionPersistence.list() // every stored session
44
44
  ```
45
45
 
46
- `append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create` 保持惰性;只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复;`inspect` 读取同一视图但不提交恢复。从水位恢复的消费方可以只读取该序列号及之后的已存储事件,会话的产物位置(`locate`)不经文件系统 I/O 即可解析。
46
+ `append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create(meta, inheritedEventCount)` 保持惰性;`meta.isSeeded: true` 要求单独的精确 cut,unseeded metadata 可以省略它并拒绝非零值。seeded 会话的首个物化批次必须到达完整继承前缀,因此存储绝不公开 cut 超过日志的 metadata。只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复;`inspect` 读取同一份完整视图但不提交恢复。`readFrom` 接受 `SessionLogOffset`,并返回分离的 `SessionEventSuffix`,其中携带该 `fromSeq`、不变的继承 cut,以及 cut 位置或之后的存储事件。会话的产物位置(`locate`)不经文件系统 I/O 即可解析。
47
47
 
48
48
  ### 恢复与崩溃恢复
49
49
 
50
- 恢复就是 `load` 加会话准备:存储日志连同其头部血缘一起返回,因此恢复后的 agent(智能体)看到相同的历史与组装。中途崩溃的会话重新加载时,其被中断的最终轮次会保留并保持平衡:`load` 为未获回答的调用追加合成 `tool/result` 与 `turn/end {interrupted}` closer,而不是丢弃事件——单个轮次可能很大,而这些事件在崩溃前已持久写入。只有从未完整写入的撕裂尾部碎片会被丢弃。
50
+ 恢复就是 `load` 加会话准备:存储日志连同其 header 血缘与精确继承切点一起返回,因此所有权检查不从标记或完整恢复长度推断切点。中途崩溃的会话重新加载时,其被中断的最终轮次会保留并保持平衡:`load` 为未获回答的调用追加合成 `tool/result` 与 `turn/end {interrupted}` closer,而不是丢弃事件——单个轮次可能很大,而这些事件在崩溃前已持久写入。只有从未完整写入的撕裂尾部碎片会被丢弃。
51
51
 
52
52
  ### 失败与恢复
53
53
 
@@ -83,7 +83,7 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi
83
83
  | [`src/write-behind.ts`](src/write-behind.ts) | 每会话有界写入控制器与 flush 屏障 |
84
84
  | [`src/preparations.ts`](src/preparations.ts) | 为恢复复用而有界保留的未发布 Session 准备结果 |
85
85
  | [`src/revision.ts`](src/revision.ts) | 带品牌类型的不透明修订值 token |
86
- | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;协调器断言存储/活动身份与 cwd |
86
+ | | 不发布运行时不变式伴生入口;协调器断言存储/活动身份与 cwd |
87
87
 
88
88
  ### 写入路径概览
89
89
 
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
- import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, adoptSessionEvent, interruptedTurnClosers, snapshotSessionEvent } from "@deepseek-ai/dsh-session";
2
+ import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionLogOffset, SessionPreparation, SessionSeq, adoptSessionEvent, interruptedTurnClosers, snapshotSessionEvent } from "@deepseek-ai/dsh-session";
3
3
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
4
4
  import { snapshotJsonValue } from "@deepseek-ai/dsh-util-values";
5
5
  //#region lib/types/revision.js
@@ -549,6 +549,20 @@ function seedCoversPrefix(seed, prefix) {
549
549
  return seedEvent !== void 0 && JSON.stringify(seedEvent) === JSON.stringify(event);
550
550
  });
551
551
  }
552
+ /** Normalize the exact fork cut paired with one logical Session header. */
553
+ function storageMetadata(meta, inheritedEventCount) {
554
+ if (meta.isSeeded && inheritedEventCount === void 0) throw new TypeError("seeded session metadata requires an inherited event count");
555
+ const cut = SessionLogOffset(inheritedEventCount ?? 0);
556
+ if (!meta.isSeeded && cut !== 0) throw new TypeError("unseeded session metadata inherited event count must be 0");
557
+ return {
558
+ meta,
559
+ inheritedEventCount: cut
560
+ };
561
+ }
562
+ /** Exact storage metadata owned by one live Session. */
563
+ function sessionStorageMetadata(session) {
564
+ return storageMetadata(session.header, session.inheritedEventCount);
565
+ }
552
566
  /** Reject events from an obsolete v0 vocabulary that this build cannot replay. */
553
567
  function assertSupportedEvents(events, id) {
554
568
  const legacyType = "request/header-delta";
@@ -576,7 +590,12 @@ function legacyMessageId(id, seq) {
576
590
  /** Read a replacement target while leaving malformed surface metadata to the session validator. */
577
591
  function replacementStart(event) {
578
592
  const op = asRecord(event.surfaceOp);
579
- return op?.["op"] === "replace" && typeof op["start"] === "number" ? op["start"] : void 0;
593
+ if (op?.["op"] !== "replace" || typeof op["start"] !== "number") return void 0;
594
+ try {
595
+ return SessionSeq(op["start"]);
596
+ } catch {
597
+ return;
598
+ }
580
599
  }
581
600
  /** Whether one suffix event needs facts available only from the preceding stored prefix. */
582
601
  function needsLegacyPrefix(event) {
@@ -852,12 +871,21 @@ var PersistenceCoordinator = class {
852
871
  /**
853
872
  * Register detached session metadata for lazy creation on the first append.
854
873
  * @param meta - header to snapshot; duplicate tracked or persisted ids reject.
874
+ * @param inheritedEventCount - exact inherited prefix length; required for
875
+ * a seeded header and omitted only for an unseeded header.
855
876
  */
856
- create(meta) {
877
+ create(meta, inheritedEventCount) {
857
878
  const snapshot = snapshotJsonValue(meta);
858
879
  if (snapshot === void 0) return Promise.reject(/* @__PURE__ */ new TypeError("session metadata must be losslessly JSON-serializable"));
859
880
  if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) return Promise.reject(/* @__PURE__ */ new TypeError("session metadata createdAt must be a non-negative safe integer"));
860
- return this.serialize(snapshot.id, () => this.createCore(snapshot));
881
+ let storage;
882
+ try {
883
+ storage = storageMetadata(snapshot, inheritedEventCount);
884
+ } catch (error) {
885
+ /* v8 ignore next -- Session storage validation only throws Error instances. */
886
+ return Promise.reject(error instanceof Error ? error : new TypeError("invalid session storage metadata", { cause: error }));
887
+ }
888
+ return this.serialize(snapshot.id, () => this.createCore(storage));
861
889
  }
862
890
  /**
863
891
  * Materialize one exact live session without inventing a session event.
@@ -871,17 +899,18 @@ var PersistenceCoordinator = class {
871
899
  if (state === void 0) throw new Error(`session "${session.id}" is not registered for persistence`);
872
900
  if (state.materialized) return;
873
901
  if (this.backend.materializeHeader === void 0) throw new Error("session persistence backend cannot materialize an empty session");
874
- await this.backend.materializeHeader(state.meta);
902
+ await this.backend.materializeHeader(state.storage);
875
903
  state.materialized = true;
876
904
  this.preparations.invalidate(session.id);
877
905
  });
878
906
  }
879
- async createCore(meta) {
907
+ async createCore(storage) {
908
+ const { meta } = storage;
880
909
  if (this.states.has(meta.id) || this.preparations.has(meta.id)) throw new Error(`session "${meta.id}" already exists in this backend`);
881
910
  if (await this.backend.loadStored(meta.id) !== void 0) throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`);
882
911
  this.states.set(meta.id, {
883
- meta,
884
- cursor: 0,
912
+ storage,
913
+ cursor: SessionLogOffset(0),
885
914
  materialized: false
886
915
  });
887
916
  }
@@ -908,9 +937,11 @@ var PersistenceCoordinator = class {
908
937
  const current = await this.backend.readStoredRevision(id);
909
938
  if (current !== state.revision) throw new Error(`session "${id}" log was advanced by another writer (expected revision ${String(state.revision)}, found ${String(current)}): refusing to append — another harness process is sharing this sessions root; continue the session in the process that owns it, or restart it here`);
910
939
  }
940
+ const nextCursor = SessionLogOffset(state.cursor + events.length);
941
+ if (!state.materialized && nextCursor < state.storage.inheritedEventCount) throw new Error(`session "${id}" cannot materialize before its inherited prefix is complete`);
911
942
  let appended = false;
912
943
  try {
913
- await this.backend.appendBatch(state.meta, events, state.materialized);
944
+ await this.backend.appendBatch(state.storage, events, state.materialized);
914
945
  appended = true;
915
946
  } finally {
916
947
  if (!appended && state.revision !== void 0) try {
@@ -918,7 +949,7 @@ var PersistenceCoordinator = class {
918
949
  } catch {}
919
950
  }
920
951
  state.materialized = true;
921
- state.cursor += events.length;
952
+ state.cursor = nextCursor;
922
953
  try {
923
954
  await this.refreshRevision(id, state);
924
955
  } catch {
@@ -955,7 +986,7 @@ var PersistenceCoordinator = class {
955
986
  throw new Error(`cannot prepare session "${id}" while it is live`);
956
987
  }
957
988
  return SessionPreparation.create(reservation.source.session, { release: () => {
958
- this.preparations.release(reservation, reservation.state.owner === void 0 && reservation.source.session.events.length === reservation.source.sessionLength);
989
+ this.preparations.release(reservation, reservation.state.owner === void 0 && reservation.source.session.seq === reservation.source.sessionLength);
959
990
  } });
960
991
  }
961
992
  }
@@ -1085,10 +1116,15 @@ var PersistenceCoordinator = class {
1085
1116
  * @param id - persisted session to read.
1086
1117
  * @param fromSeq - first event seq to include; a non-negative safe integer.
1087
1118
  * @param signal - optional cancellation for queued and backend read work.
1088
- * @returns stored header and the valid stored events with `seq >= fromSeq`.
1119
+ * @returns stored metadata, the requested offset, and valid events with `seq >= fromSeq`.
1089
1120
  */
1090
1121
  readFrom(id, fromSeq, signal) {
1091
- if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) return Promise.reject(/* @__PURE__ */ new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`));
1122
+ try {
1123
+ SessionLogOffset(fromSeq);
1124
+ } catch (error) {
1125
+ /* v8 ignore next -- Session log-offset validation only throws Error instances. */
1126
+ return Promise.reject(error instanceof Error ? error : new TypeError("invalid session read offset", { cause: error }));
1127
+ }
1092
1128
  const retired = Promise.resolve(this.retirements.get(id));
1093
1129
  return (signal === void 0 ? retired : observeQueuedAbort(retired, signal, () => false)).then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal));
1094
1130
  }
@@ -1110,6 +1146,8 @@ var PersistenceCoordinator = class {
1110
1146
  const whole = await this.readStoredPrefix(id, signal);
1111
1147
  return {
1112
1148
  meta: whole.meta,
1149
+ inheritedEventCount: whole.inheritedEventCount,
1150
+ fromSeq,
1113
1151
  events: whole.events.filter((event) => event.seq >= fromSeq)
1114
1152
  };
1115
1153
  }
@@ -1117,12 +1155,16 @@ var PersistenceCoordinator = class {
1117
1155
  this.assertEventsSupported(suffix.meta, events);
1118
1156
  return {
1119
1157
  meta: structuredClone(suffix.meta),
1158
+ inheritedEventCount: SessionLogOffset(suffix.inheritedEventCount),
1159
+ fromSeq,
1120
1160
  events
1121
1161
  };
1122
1162
  }
1123
1163
  const whole = await this.readStoredPrefix(id, signal);
1124
1164
  return {
1125
1165
  meta: whole.meta,
1166
+ inheritedEventCount: whole.inheritedEventCount,
1167
+ fromSeq,
1126
1168
  events: whole.events.slice(fromSeq)
1127
1169
  };
1128
1170
  }
@@ -1138,6 +1180,7 @@ var PersistenceCoordinator = class {
1138
1180
  this.assertEventsSupported(stored.meta, events);
1139
1181
  return {
1140
1182
  meta: structuredClone(stored.meta),
1183
+ inheritedEventCount: SessionLogOffset(stored.inheritedEventCount),
1141
1184
  events
1142
1185
  };
1143
1186
  }
@@ -1146,26 +1189,29 @@ var PersistenceCoordinator = class {
1146
1189
  const stored = await this.backend.loadStored(id);
1147
1190
  if (stored === void 0) throw new SessionPersistenceNotFoundError(id);
1148
1191
  try {
1149
- const { meta, events, revision, tornMarker } = stored;
1192
+ const { meta, inheritedEventCount, events, revision, tornMarker } = stored;
1150
1193
  this.assertStoredId(id, meta);
1151
1194
  this.assertVersion(meta);
1152
1195
  const storedEvents = adoptStoredEvents(events, id);
1153
1196
  this.assertEventsSupported(meta, storedEvents);
1197
+ if (inheritedEventCount > storedEvents.length) throw new Error(`session "${id}" inherited event count exceeds its stored event count`);
1154
1198
  const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent);
1155
1199
  const balanced = [...storedEvents, ...closers];
1156
1200
  const session = this.ctx.sessions.prepare(id, {
1157
1201
  seed: balanced,
1158
1202
  meta,
1203
+ inheritedEventCount,
1159
1204
  seedSource: "persistence"
1160
1205
  });
1161
1206
  return {
1162
1207
  inspection: Object.freeze({
1163
1208
  meta: session.header,
1209
+ inheritedEventCount: session.inheritedEventCount,
1164
1210
  events: Object.freeze(balanced)
1165
1211
  }),
1166
1212
  session,
1167
1213
  revision,
1168
- sessionLength: session.events.length,
1214
+ sessionLength: session.seq,
1169
1215
  tornMarker,
1170
1216
  closers
1171
1217
  };
@@ -1177,20 +1223,20 @@ var PersistenceCoordinator = class {
1177
1223
  /** Commit one prepared repair and establish its ownerless durable cursor. */
1178
1224
  async commitPrepared(source) {
1179
1225
  const id = source.inspection.meta.id;
1180
- const cursor = source.inspection.events.length;
1226
+ const cursor = SessionLogOffset(source.inspection.events.length);
1181
1227
  const existing = this.states.get(id);
1182
1228
  if (existing?.owner !== void 0) throw new Error(`session "${id}" already has a live persistence owner`);
1183
1229
  if (!await this.isPreparedSourceCurrent(source)) return void 0;
1184
1230
  if (source.tornMarker !== void 0 || source.closers.length > 0) {
1185
- await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers);
1231
+ await this.backend.commitRepair(source.inspection, source.tornMarker, source.closers);
1186
1232
  return;
1187
1233
  }
1188
1234
  const state = existing ?? {
1189
- meta: source.inspection.meta,
1235
+ storage: source.inspection,
1190
1236
  cursor,
1191
1237
  materialized: true
1192
1238
  };
1193
- state.meta = source.inspection.meta;
1239
+ state.storage = source.inspection;
1194
1240
  state.cursor = cursor;
1195
1241
  state.materialized = true;
1196
1242
  state.revision = source.revision;
@@ -1206,7 +1252,7 @@ var PersistenceCoordinator = class {
1206
1252
  }
1207
1253
  /** Return one durable immutable view of an already-live Session. */
1208
1254
  async loadLiveSnapshot(session) {
1209
- const events = session.events;
1255
+ const events = session.snapshotEvents();
1210
1256
  await this.flush(session);
1211
1257
  const state = this.states.get(session.id);
1212
1258
  /* v8 ignore next -- successful flush always publishes this live session's durable state */
@@ -1214,7 +1260,8 @@ var PersistenceCoordinator = class {
1214
1260
  if (events.length === 0 && !state.materialized) throw new Error(`session "${session.id}" not found`);
1215
1261
  if (interruptedTurnClosers(events).length > 0) throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`);
1216
1262
  return Object.freeze({
1217
- meta: state.meta,
1263
+ meta: state.storage.meta,
1264
+ inheritedEventCount: state.storage.inheritedEventCount,
1218
1265
  events
1219
1266
  });
1220
1267
  }
@@ -1222,7 +1269,8 @@ var PersistenceCoordinator = class {
1222
1269
  inspectLive(session) {
1223
1270
  return Object.freeze({
1224
1271
  meta: session.header,
1225
- events: session.events
1272
+ inheritedEventCount: session.inheritedEventCount,
1273
+ events: session.snapshotEvents()
1226
1274
  });
1227
1275
  }
1228
1276
  /** Await one retiring lifecycle with caller cancellation. */
@@ -1353,7 +1401,7 @@ var PersistenceCoordinator = class {
1353
1401
  this.live.set(session, restored);
1354
1402
  return restored;
1355
1403
  }
1356
- const seed = session.events;
1404
+ const seed = session.snapshotEvents();
1357
1405
  const live = {
1358
1406
  init: Promise.resolve(),
1359
1407
  writes: this.createWriteBehind(session, () => live.init)
@@ -1367,7 +1415,7 @@ var PersistenceCoordinator = class {
1367
1415
  attachPrepared(session, reservation) {
1368
1416
  const { source, state } = reservation;
1369
1417
  if (source.session !== session || state.owner !== void 0 || state.cursor !== source.inspection.events.length || session.firstLiveSeq !== state.cursor) throw new Error(`session "${session.id}" preparation no longer matches its persistence state`);
1370
- const suffix = session.events.slice(state.cursor).map((event) => structuredClone(event));
1418
+ const suffix = session.snapshotEvents(state.cursor).map((event) => structuredClone(event));
1371
1419
  this.preparations.attach(reservation);
1372
1420
  state.owner = session;
1373
1421
  const live = {
@@ -1413,7 +1461,8 @@ var PersistenceCoordinator = class {
1413
1461
  /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */
1414
1462
  if (tracked.owner === session) return;
1415
1463
  if (tracked.owner === void 0) {
1416
- if (tracked.meta.cwd !== session.header.cwd) throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`);
1464
+ if (tracked.storage.meta.cwd !== session.header.cwd) throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.storage.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`);
1465
+ if (tracked.storage.inheritedEventCount !== session.inheritedEventCount) throw new Error(`session "${id}" is already persisted with a different inherited event count (id collision)`);
1417
1466
  if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`);
1418
1467
  tracked.owner = session;
1419
1468
  const suffix = seed.slice(tracked.cursor);
@@ -1429,8 +1478,11 @@ var PersistenceCoordinator = class {
1429
1478
  await this.adoptLivePrefix(session, seed, live);
1430
1479
  return;
1431
1480
  }
1432
- const meta = { ...session.header };
1433
- await this.createCore(meta);
1481
+ const storage = sessionStorageMetadata(session);
1482
+ await this.createCore({
1483
+ meta: { ...storage.meta },
1484
+ inheritedEventCount: storage.inheritedEventCount
1485
+ });
1434
1486
  const created = this.states.get(id);
1435
1487
  /* v8 ignore next -- create() always sets the state for the id */
1436
1488
  if (created !== void 0) created.owner = session;
@@ -1443,17 +1495,21 @@ var PersistenceCoordinator = class {
1443
1495
  * live suffix that was ahead of the stored prefix.
1444
1496
  */
1445
1497
  async adoptLivePrefix(session, seed, stored) {
1446
- const { meta, events, tornMarker } = stored;
1498
+ const { meta, inheritedEventCount, events, tornMarker } = stored;
1447
1499
  this.assertStoredId(session.header.id, meta);
1448
1500
  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)`);
1501
+ if (inheritedEventCount !== session.inheritedEventCount) throw new Error(`session "${session.header.id}" is already persisted with a different inherited event count (id collision)`);
1449
1502
  this.assertVersion(meta);
1450
1503
  const storedEvents = snapshotStoredEvents(events, session.header.id);
1451
1504
  this.assertEventsSupported(meta, storedEvents);
1452
1505
  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)`);
1453
- if (tornMarker !== void 0) await this.backend.commitRepair(meta, tornMarker, []);
1506
+ if (tornMarker !== void 0) await this.backend.commitRepair(stored, tornMarker, []);
1454
1507
  const state = {
1455
- meta: { ...meta },
1456
- cursor: storedEvents.length,
1508
+ storage: {
1509
+ meta: { ...meta },
1510
+ inheritedEventCount
1511
+ },
1512
+ cursor: SessionLogOffset(storedEvents.length),
1457
1513
  materialized: true,
1458
1514
  owner: session,
1459
1515
  ...tornMarker === void 0 ? { revision: stored.revision } : {}
@@ -1560,6 +1616,7 @@ var SessionPersistence = class extends Service {
1560
1616
  return SessionPreparation.create(sessions.prepare(id, {
1561
1617
  seed: loaded.events.map((event) => structuredClone(event)),
1562
1618
  meta: structuredClone(loaded.meta),
1619
+ inheritedEventCount: SessionLogOffset(loaded.inheritedEventCount),
1563
1620
  seedSource: "persistence"
1564
1621
  }));
1565
1622
  }
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import { Context } from '@deepseek-ai/cordis';
8
8
  import { SessionPreparation } from '@deepseek-ai/dsh-session';
9
- import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session';
10
- import type { BorrowedSessionSource, SessionInspection, SessionLocation } from './index.ts';
9
+ import type { Session, SessionEvent, SessionId, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session';
10
+ import type { BorrowedSessionSource, SessionEventSuffix, SessionInspection, SessionLocation, SessionStorageMetadata } from './index.ts';
11
11
  import type { SessionPersistenceRevision } from './revision.ts';
12
12
  /** Default number of detached session preparations retained by a coordinator. */
13
13
  export declare const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5;
@@ -65,8 +65,7 @@ export interface PersistenceCoordinatorOptions {
65
65
  * returns its value to {@link PersistenceBackend.commitRepair}; each backend
66
66
  * owns the marker type.
67
67
  */
68
- export interface StoredPrefix<TornMarker = unknown> {
69
- meta: SessionHeader;
68
+ export interface StoredPrefix<TornMarker = unknown> extends SessionStorageMetadata {
70
69
  events: SessionEvent[];
71
70
  /** Revision observed for exactly this detached prefix. */
72
71
  revision: SessionPersistenceRevision;
@@ -78,8 +77,7 @@ export interface StoredPrefix<TornMarker = unknown> {
78
77
  * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no
79
78
  * torn marker: there is nothing to repair.
80
79
  */
81
- export interface StoredSuffix {
82
- meta: SessionHeader;
80
+ export interface StoredSuffix extends SessionStorageMetadata {
83
81
  events: SessionEvent[];
84
82
  }
85
83
  /**
@@ -141,9 +139,9 @@ export interface PersistenceBackend<TornMarker = unknown> {
141
139
  * validated by the coordinator before this hook runs).
142
140
  * @param signal - optional cancellation for backend read work.
143
141
  */
144
- loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined>;
142
+ loadStoredFrom?(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise<StoredSuffix | undefined>;
145
143
  /** Durably create an empty header-only session artifact. */
146
- materializeHeader?(meta: SessionHeader): Promise<void>;
144
+ materializeHeader?(storage: SessionStorageMetadata): Promise<void>;
147
145
  /**
148
146
  * Durably append a CONTIGUOUS batch, lazily materializing the session first
149
147
  * when `!isMaterialized`. The materialize-write and the first event batch MUST
@@ -151,8 +149,10 @@ export interface PersistenceBackend<TornMarker = unknown> {
151
149
  * empty session). Returns once the batch is durable. A rejection must leave
152
150
  * the stored log unchanged (roll partial work back): the coordinator keeps
153
151
  * its cursor across the failure and retries the same batch.
152
+ * The coordinator calls this only after a first batch reaches the declared
153
+ * inherited prefix length.
154
154
  */
155
- appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void>;
155
+ appendBatch(storage: SessionStorageMetadata, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void>;
156
156
  /**
157
157
  * Make a crash repair durable: truncate the torn tail (iff
158
158
  * `tornMarker !== undefined`) and append `closers` (iff any). NOT required to
@@ -160,7 +160,7 @@ export interface PersistenceBackend<TornMarker = unknown> {
160
160
  * Used by load (truncate + synthetic closers) and by live-adoption (truncate
161
161
  * only, `closers = []`).
162
162
  */
163
- commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>;
163
+ commitRepair(storage: SessionStorageMetadata, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>;
164
164
  /**
165
165
  * List all stored (materialized) sessions' metadata.
166
166
  * @param signal - optional cancellation for backend listing work.
@@ -215,8 +215,10 @@ export declare class PersistenceCoordinator<TornMarker = unknown> {
215
215
  /**
216
216
  * Register detached session metadata for lazy creation on the first append.
217
217
  * @param meta - header to snapshot; duplicate tracked or persisted ids reject.
218
+ * @param inheritedEventCount - exact inherited prefix length; required for
219
+ * a seeded header and omitted only for an unseeded header.
218
220
  */
219
- create(meta: SessionHeader): Promise<void>;
221
+ create(meta: SessionHeader, inheritedEventCount?: SessionLogOffsetType): Promise<void>;
220
222
  /**
221
223
  * Materialize one exact live session without inventing a session event.
222
224
  * @param session - live session already registered through the write path.
@@ -282,12 +284,9 @@ export declare class PersistenceCoordinator<TornMarker = unknown> {
282
284
  * @param id - persisted session to read.
283
285
  * @param fromSeq - first event seq to include; a non-negative safe integer.
284
286
  * @param signal - optional cancellation for queued and backend read work.
285
- * @returns stored header and the valid stored events with `seq >= fromSeq`.
287
+ * @returns stored metadata, the requested offset, and valid events with `seq >= fromSeq`.
286
288
  */
287
- readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{
288
- meta: SessionHeader;
289
- events: SessionEvent[];
290
- }>;
289
+ readFrom(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise<SessionEventSuffix>;
291
290
  private readFromCore;
292
291
  /** Read one detached physical prefix without logical recovery or caching. */
293
292
  private readStoredPrefix;
@@ -5,7 +5,7 @@
5
5
  * @module @deepseek-ai/dsh-session-persistence
6
6
  */
7
7
  import { Context, Service } from '@deepseek-ai/cordis';
8
- import { SessionPreparation } from '@deepseek-ai/dsh-session';
8
+ import { SessionPreparation, SessionLogOffset } from '@deepseek-ai/dsh-session';
9
9
  import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session';
10
10
  import type { SessionPersistenceRevision } from './revision.ts';
11
11
  export type { SessionHeader } from '@deepseek-ai/dsh-session';
@@ -18,13 +18,25 @@ export interface SessionPersistenceSnapshot {
18
18
  /** Opaque source-qualified token that changes whenever this stored log changes. */
19
19
  revision: SessionPersistenceRevision;
20
20
  }
21
- /** Immutable logical session prepared from persistence or a live owner. */
22
- export interface SessionInspection {
23
- /** Validated immutable session metadata. */
21
+ /** Logical Session header paired with its exact inherited cut for body-bearing storage operations. */
22
+ export interface SessionStorageMetadata {
23
+ /** Validated immutable Session header. */
24
24
  readonly meta: SessionHeader;
25
+ /** Number of leading events inherited from the Session's fork parent. */
26
+ readonly inheritedEventCount: SessionLogOffset;
27
+ }
28
+ /** Immutable logical session prepared from persistence or a live owner. */
29
+ export interface SessionInspection extends SessionStorageMetadata {
25
30
  /** Validated contiguous logical event log. */
26
31
  readonly events: readonly SessionEvent[];
27
32
  }
33
+ /** Detached logical suffix returned by one explicit stored-log offset read. */
34
+ export interface SessionEventSuffix extends SessionStorageMetadata {
35
+ /** First requested log offset; {@link events} contains only seqs at or after it. */
36
+ readonly fromSeq: SessionLogOffset;
37
+ /** Valid contiguous stored events at or after {@link fromSeq}; not a complete Session log when the offset is nonzero. */
38
+ readonly events: readonly SessionEvent[];
39
+ }
28
40
  /** A borrowed exact Session source returned from a cold materialization or concurrent live owner. */
29
41
  export type BorrowedSessionSource = Disposable & ({
30
42
  /** A reusable unpublished Session is pinned until this observation is disposed. */
@@ -42,9 +54,7 @@ export type BorrowedSessionSource = Disposable & ({
42
54
  readonly inspection: SessionInspection;
43
55
  });
44
56
  /** A backend's own raw artifact text for one session, verbatim. */
45
- export interface SessionRawArtifact {
46
- /** The session header parsed from the artifact's own first line. */
47
- readonly meta: SessionHeader;
57
+ export interface SessionRawArtifact extends SessionStorageMetadata {
48
58
  /** The artifact's base filename on disk, without any physical encoding suffix. */
49
59
  readonly filename: string;
50
60
  /** The artifact's full text content, decoded from the backend's physical encoding. */
@@ -111,8 +121,10 @@ export declare abstract class SessionPersistence extends Service {
111
121
  * created-but-never-appended session is absent from {@link list}
112
122
  * — abandoned sessions leave nothing behind.
113
123
  * @param meta - the immutable header (id, version, cwd, lineage) to record.
124
+ * @param inheritedEventCount - exact fork-inherited prefix length. Required
125
+ * for a seeded header and omitted only for an unseeded header.
114
126
  */
115
- abstract create(meta: SessionHeader): Promise<void>;
127
+ abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise<void>;
116
128
  /**
117
129
  * Ensure a live session has a durable header even when it has no events.
118
130
  * Ordinary sessions remain lazily materialized; lifecycle frontends call
@@ -128,6 +140,8 @@ export declare abstract class SessionPersistence extends Service {
128
140
  * Coordinator-backed implementations also reject when the durable log
129
141
  * advanced since this process last observed it — another harness process
130
142
  * sharing the sessions root — rather than interleave duplicate seqs.
143
+ * A seeded session's first materializing batch must reach its complete
144
+ * inherited prefix.
131
145
  * @param id - the session the batch belongs to.
132
146
  * @param events - the contiguous batch to persist, in seq order.
133
147
  */
@@ -197,14 +211,11 @@ export declare abstract class SessionPersistence extends Service {
197
211
  * forward. The primitive bounds what is returned and refolded, not every
198
212
  * backend's physical read.
199
213
  * @param id - the persisted session to read.
200
- * @param fromSeq - first event seq to include; a non-negative safe integer.
214
+ * @param fromSeq - first event offset to include.
201
215
  * @param signal - optional cancellation for queued and backend read work.
202
- * @returns the header and the stored events with `seq >= fromSeq`.
216
+ * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`.
203
217
  */
204
- abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{
205
- meta: SessionHeader;
206
- events: SessionEvent[];
207
- }>;
218
+ abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise<SessionEventSuffix>;
208
219
  /**
209
220
  * Lightweight listing from metadata, without a full-log parse.
210
221
  * @param signal - optional cancellation for backend listing work.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@crazx/dsh-session-persistence",
3
3
  "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness",
4
- "version": "0.1.2-alpha.3.zw.2",
4
+ "version": "0.1.2-alpha.5.zw.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -18,35 +18,28 @@
18
18
  "types": "./lib/types/index.d.ts",
19
19
  "default": "./lib/index.js"
20
20
  },
21
- "./invariant": {
22
- "types": "./lib/types/invariant.d.ts",
23
- "default": "./lib/invariant.js"
24
- },
25
21
  "./src/*": "./src/*",
26
22
  "./package.json": "./package.json"
27
23
  },
28
24
  "files": [
29
25
  "lib/index.js",
30
- "lib/invariant.js",
31
26
  "lib/types/**/*.d.ts"
32
27
  ],
33
28
  "license": "MIT",
34
29
  "peerDependencies": {
35
30
  "@deepseek-ai/cordis": "^4.0.2",
36
- "@deepseek-ai/dsh-brand": "^0.1.2-alpha.3",
37
- "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
38
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.3",
39
- "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.3"
31
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.5",
32
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
33
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.5"
40
34
  },
41
35
  "devDependencies": {
42
36
  "@deepseek-ai/cordis": "^4.0.2",
43
- "@deepseek-ai/dsh-brand": "^0.1.2-alpha.3",
44
- "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
45
- "@deepseek-ai/dsh-scope": "^0.1.2-alpha.3",
46
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.3",
47
- "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.3"
37
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.5",
38
+ "@deepseek-ai/dsh-scope": "^0.1.2-alpha.5",
39
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
40
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.5"
48
41
  },
49
42
  "dependencies": {
50
- "@deepseek-ai/dsh-util-values": "^0.1.2-alpha.3"
43
+ "@deepseek-ai/dsh-util-values": "^0.1.2-alpha.5"
51
44
  }
52
45
  }
package/lib/invariant.js DELETED
@@ -1,23 +0,0 @@
1
- //#region lib/types/invariant.js
2
- /**
3
- * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`.
4
- * @module @deepseek-ai/dsh-session-persistence/invariant
5
- */
6
- const PACKAGE_NAME = "@deepseek-ai/dsh-session-persistence";
7
- /** Cordis companion plugin name. */
8
- const name = "session-persistence-invariant";
9
- /** Service required before the companion can reserve package ownership. */
10
- const inject = ["invariants"];
11
- /**
12
- * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
13
- * this package exposes no continuously observable in-process relation.
14
- */
15
- const install = () => {};
16
- /**
17
- * Register this package's invariant companion.
18
- * @param ctx - Cordis context carrying the invariant service.
19
- * @returns the installed registration's disposer after setup succeeds.
20
- */
21
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
- //#endregion
23
- export { apply, inject, name };
@@ -1,16 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`.
3
- * @module @deepseek-ai/dsh-session-persistence/invariant
4
- */
5
- import type { Context } from '@deepseek-ai/cordis';
6
- /** Cordis companion plugin name. */
7
- export declare const name = "session-persistence-invariant";
8
- /** Service required before the companion can reserve package ownership. */
9
- export declare const inject: string[];
10
- /**
11
- * Register this package's invariant companion.
12
- * @param ctx - Cordis context carrying the invariant service.
13
- * @returns the installed registration's disposer after setup succeeds.
14
- */
15
- export declare const apply: (ctx: Context) => Promise<() => void>;
16
- //# sourceMappingURL=invariant.d.ts.map