@deepseek-ai/dsh-session-persistence 0.1.2-alpha.3 → 0.1.2-alpha.5

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: 687241c604254152538e152a62d0c78b31369cf3
6
- README.zh.md: 170f194c247bf680db554c4ecb2beca97184ff0b
5
+ README.md: 6d430173b985d7e32b615e0b42713a925bc8b344
6
+ README.zh.md: a3b6cd90fa4ccba2b28f0e81517bbabab0ce047e
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
  }
@@ -904,9 +933,11 @@ var PersistenceCoordinator = class {
904
933
  let state = this.states.get(id);
905
934
  if (state === void 0) state = await this.adopt(id);
906
935
  for (const [i, event] of events.entries()) if (event.seq !== state.cursor + i) throw new Error(`append seq mismatch for "${id}": expected ${state.cursor + i} at index ${i}, got ${event.seq}`);
907
- await this.backend.appendBatch(state.meta, events, state.materialized);
936
+ const nextCursor = SessionLogOffset(state.cursor + events.length);
937
+ if (!state.materialized && nextCursor < state.storage.inheritedEventCount) throw new Error(`session "${id}" cannot materialize before its inherited prefix is complete`);
938
+ await this.backend.appendBatch(state.storage, events, state.materialized);
908
939
  state.materialized = true;
909
- state.cursor += events.length;
940
+ state.cursor = nextCursor;
910
941
  this.preparations.invalidate(id);
911
942
  }
912
943
  /**
@@ -928,7 +959,7 @@ var PersistenceCoordinator = class {
928
959
  throw new Error(`cannot prepare session "${id}" while it is live`);
929
960
  }
930
961
  return SessionPreparation.create(reservation.source.session, { release: () => {
931
- this.preparations.release(reservation, reservation.state.owner === void 0 && reservation.source.session.events.length === reservation.source.sessionLength);
962
+ this.preparations.release(reservation, reservation.state.owner === void 0 && reservation.source.session.seq === reservation.source.sessionLength);
932
963
  } });
933
964
  }
934
965
  }
@@ -1058,10 +1089,15 @@ var PersistenceCoordinator = class {
1058
1089
  * @param id - persisted session to read.
1059
1090
  * @param fromSeq - first event seq to include; a non-negative safe integer.
1060
1091
  * @param signal - optional cancellation for queued and backend read work.
1061
- * @returns stored header and the valid stored events with `seq >= fromSeq`.
1092
+ * @returns stored metadata, the requested offset, and valid events with `seq >= fromSeq`.
1062
1093
  */
1063
1094
  readFrom(id, fromSeq, signal) {
1064
- if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) return Promise.reject(/* @__PURE__ */ new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`));
1095
+ try {
1096
+ SessionLogOffset(fromSeq);
1097
+ } catch (error) {
1098
+ /* v8 ignore next -- Session log-offset validation only throws Error instances. */
1099
+ return Promise.reject(error instanceof Error ? error : new TypeError("invalid session read offset", { cause: error }));
1100
+ }
1065
1101
  const retired = Promise.resolve(this.retirements.get(id));
1066
1102
  return (signal === void 0 ? retired : observeQueuedAbort(retired, signal, () => false)).then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal));
1067
1103
  }
@@ -1083,6 +1119,8 @@ var PersistenceCoordinator = class {
1083
1119
  const whole = await this.readStoredPrefix(id, signal);
1084
1120
  return {
1085
1121
  meta: whole.meta,
1122
+ inheritedEventCount: whole.inheritedEventCount,
1123
+ fromSeq,
1086
1124
  events: whole.events.filter((event) => event.seq >= fromSeq)
1087
1125
  };
1088
1126
  }
@@ -1090,12 +1128,16 @@ var PersistenceCoordinator = class {
1090
1128
  this.assertEventsSupported(suffix.meta, events);
1091
1129
  return {
1092
1130
  meta: structuredClone(suffix.meta),
1131
+ inheritedEventCount: SessionLogOffset(suffix.inheritedEventCount),
1132
+ fromSeq,
1093
1133
  events
1094
1134
  };
1095
1135
  }
1096
1136
  const whole = await this.readStoredPrefix(id, signal);
1097
1137
  return {
1098
1138
  meta: whole.meta,
1139
+ inheritedEventCount: whole.inheritedEventCount,
1140
+ fromSeq,
1099
1141
  events: whole.events.slice(fromSeq)
1100
1142
  };
1101
1143
  }
@@ -1111,6 +1153,7 @@ var PersistenceCoordinator = class {
1111
1153
  this.assertEventsSupported(stored.meta, events);
1112
1154
  return {
1113
1155
  meta: structuredClone(stored.meta),
1156
+ inheritedEventCount: SessionLogOffset(stored.inheritedEventCount),
1114
1157
  events
1115
1158
  };
1116
1159
  }
@@ -1119,26 +1162,29 @@ var PersistenceCoordinator = class {
1119
1162
  const stored = await this.backend.loadStored(id);
1120
1163
  if (stored === void 0) throw new SessionPersistenceNotFoundError(id);
1121
1164
  try {
1122
- const { meta, events, revision, tornMarker } = stored;
1165
+ const { meta, inheritedEventCount, events, revision, tornMarker } = stored;
1123
1166
  this.assertStoredId(id, meta);
1124
1167
  this.assertVersion(meta);
1125
1168
  const storedEvents = adoptStoredEvents(events, id);
1126
1169
  this.assertEventsSupported(meta, storedEvents);
1170
+ if (inheritedEventCount > storedEvents.length) throw new Error(`session "${id}" inherited event count exceeds its stored event count`);
1127
1171
  const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent);
1128
1172
  const balanced = [...storedEvents, ...closers];
1129
1173
  const session = this.ctx.sessions.prepare(id, {
1130
1174
  seed: balanced,
1131
1175
  meta,
1176
+ inheritedEventCount,
1132
1177
  seedSource: "persistence"
1133
1178
  });
1134
1179
  return {
1135
1180
  inspection: Object.freeze({
1136
1181
  meta: session.header,
1182
+ inheritedEventCount: session.inheritedEventCount,
1137
1183
  events: Object.freeze(balanced)
1138
1184
  }),
1139
1185
  session,
1140
1186
  revision,
1141
- sessionLength: session.events.length,
1187
+ sessionLength: session.seq,
1142
1188
  tornMarker,
1143
1189
  closers
1144
1190
  };
@@ -1150,20 +1196,20 @@ var PersistenceCoordinator = class {
1150
1196
  /** Commit one prepared repair and establish its ownerless durable cursor. */
1151
1197
  async commitPrepared(source) {
1152
1198
  const id = source.inspection.meta.id;
1153
- const cursor = source.inspection.events.length;
1199
+ const cursor = SessionLogOffset(source.inspection.events.length);
1154
1200
  const existing = this.states.get(id);
1155
1201
  if (existing?.owner !== void 0) throw new Error(`session "${id}" already has a live persistence owner`);
1156
1202
  if (!await this.isPreparedSourceCurrent(source)) return void 0;
1157
1203
  if (source.tornMarker !== void 0 || source.closers.length > 0) {
1158
- await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers);
1204
+ await this.backend.commitRepair(source.inspection, source.tornMarker, source.closers);
1159
1205
  return;
1160
1206
  }
1161
1207
  const state = existing ?? {
1162
- meta: source.inspection.meta,
1208
+ storage: source.inspection,
1163
1209
  cursor,
1164
1210
  materialized: true
1165
1211
  };
1166
- state.meta = source.inspection.meta;
1212
+ state.storage = source.inspection;
1167
1213
  state.cursor = cursor;
1168
1214
  state.materialized = true;
1169
1215
  this.states.set(id, state);
@@ -1178,7 +1224,7 @@ var PersistenceCoordinator = class {
1178
1224
  }
1179
1225
  /** Return one durable immutable view of an already-live Session. */
1180
1226
  async loadLiveSnapshot(session) {
1181
- const events = session.events;
1227
+ const events = session.snapshotEvents();
1182
1228
  await this.flush(session);
1183
1229
  const state = this.states.get(session.id);
1184
1230
  /* v8 ignore next -- successful flush always publishes this live session's durable state */
@@ -1186,7 +1232,8 @@ var PersistenceCoordinator = class {
1186
1232
  if (events.length === 0 && !state.materialized) throw new Error(`session "${session.id}" not found`);
1187
1233
  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`);
1188
1234
  return Object.freeze({
1189
- meta: state.meta,
1235
+ meta: state.storage.meta,
1236
+ inheritedEventCount: state.storage.inheritedEventCount,
1190
1237
  events
1191
1238
  });
1192
1239
  }
@@ -1194,7 +1241,8 @@ var PersistenceCoordinator = class {
1194
1241
  inspectLive(session) {
1195
1242
  return Object.freeze({
1196
1243
  meta: session.header,
1197
- events: session.events
1244
+ inheritedEventCount: session.inheritedEventCount,
1245
+ events: session.snapshotEvents()
1198
1246
  });
1199
1247
  }
1200
1248
  /** Await one retiring lifecycle with caller cancellation. */
@@ -1325,7 +1373,7 @@ var PersistenceCoordinator = class {
1325
1373
  this.live.set(session, restored);
1326
1374
  return restored;
1327
1375
  }
1328
- const seed = session.events;
1376
+ const seed = session.snapshotEvents();
1329
1377
  const live = {
1330
1378
  init: Promise.resolve(),
1331
1379
  writes: this.createWriteBehind(session, () => live.init)
@@ -1339,7 +1387,7 @@ var PersistenceCoordinator = class {
1339
1387
  attachPrepared(session, reservation) {
1340
1388
  const { source, state } = reservation;
1341
1389
  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`);
1342
- const suffix = session.events.slice(state.cursor).map((event) => structuredClone(event));
1390
+ const suffix = session.snapshotEvents(state.cursor).map((event) => structuredClone(event));
1343
1391
  this.preparations.attach(reservation);
1344
1392
  state.owner = session;
1345
1393
  const live = {
@@ -1385,7 +1433,8 @@ var PersistenceCoordinator = class {
1385
1433
  /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */
1386
1434
  if (tracked.owner === session) return;
1387
1435
  if (tracked.owner === void 0) {
1388
- 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)`);
1436
+ 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)`);
1437
+ if (tracked.storage.inheritedEventCount !== session.inheritedEventCount) throw new Error(`session "${id}" is already persisted with a different inherited event count (id collision)`);
1389
1438
  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)`);
1390
1439
  tracked.owner = session;
1391
1440
  const suffix = seed.slice(tracked.cursor);
@@ -1401,8 +1450,11 @@ var PersistenceCoordinator = class {
1401
1450
  await this.adoptLivePrefix(session, seed, live);
1402
1451
  return;
1403
1452
  }
1404
- const meta = { ...session.header };
1405
- await this.createCore(meta);
1453
+ const storage = sessionStorageMetadata(session);
1454
+ await this.createCore({
1455
+ meta: { ...storage.meta },
1456
+ inheritedEventCount: storage.inheritedEventCount
1457
+ });
1406
1458
  const created = this.states.get(id);
1407
1459
  /* v8 ignore next -- create() always sets the state for the id */
1408
1460
  if (created !== void 0) created.owner = session;
@@ -1415,17 +1467,21 @@ var PersistenceCoordinator = class {
1415
1467
  * live suffix that was ahead of the stored prefix.
1416
1468
  */
1417
1469
  async adoptLivePrefix(session, seed, stored) {
1418
- const { meta, events, tornMarker } = stored;
1470
+ const { meta, inheritedEventCount, events, tornMarker } = stored;
1419
1471
  this.assertStoredId(session.header.id, meta);
1420
1472
  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)`);
1473
+ if (inheritedEventCount !== session.inheritedEventCount) throw new Error(`session "${session.header.id}" is already persisted with a different inherited event count (id collision)`);
1421
1474
  this.assertVersion(meta);
1422
1475
  const storedEvents = snapshotStoredEvents(events, session.header.id);
1423
1476
  this.assertEventsSupported(meta, storedEvents);
1424
1477
  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)`);
1425
- if (tornMarker !== void 0) await this.backend.commitRepair(meta, tornMarker, []);
1478
+ if (tornMarker !== void 0) await this.backend.commitRepair(stored, tornMarker, []);
1426
1479
  this.states.set(session.header.id, {
1427
- meta: { ...meta },
1428
- cursor: storedEvents.length,
1480
+ storage: {
1481
+ meta: { ...meta },
1482
+ inheritedEventCount
1483
+ },
1484
+ cursor: SessionLogOffset(storedEvents.length),
1429
1485
  materialized: true,
1430
1486
  owner: session
1431
1487
  });
@@ -1529,6 +1585,7 @@ var SessionPersistence = class extends Service {
1529
1585
  return SessionPreparation.create(sessions.prepare(id, {
1530
1586
  seed: loaded.events.map((event) => structuredClone(event)),
1531
1587
  meta: structuredClone(loaded.meta),
1588
+ inheritedEventCount: SessionLogOffset(loaded.inheritedEventCount),
1532
1589
  seedSource: "persistence"
1533
1590
  }));
1534
1591
  }
@@ -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
  /**
@@ -138,16 +136,18 @@ export interface PersistenceBackend<TornMarker = unknown> {
138
136
  * validated by the coordinator before this hook runs).
139
137
  * @param signal - optional cancellation for backend read work.
140
138
  */
141
- loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined>;
139
+ loadStoredFrom?(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise<StoredSuffix | undefined>;
142
140
  /** Durably create an empty header-only session artifact. */
143
- materializeHeader?(meta: SessionHeader): Promise<void>;
141
+ materializeHeader?(storage: SessionStorageMetadata): Promise<void>;
144
142
  /**
145
143
  * Durably append a CONTIGUOUS batch, lazily materializing the session first
146
144
  * when `!isMaterialized`. The materialize-write and the first event batch MUST
147
145
  * commit ATOMICALLY (a crash between them must not leave a materialized-but-
148
146
  * empty session). Returns once the batch is durable.
147
+ * The coordinator calls this only after a first batch reaches the declared
148
+ * inherited prefix length.
149
149
  */
150
- appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void>;
150
+ appendBatch(storage: SessionStorageMetadata, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void>;
151
151
  /**
152
152
  * Make a crash repair durable: truncate the torn tail (iff
153
153
  * `tornMarker !== undefined`) and append `closers` (iff any). NOT required to
@@ -155,7 +155,7 @@ export interface PersistenceBackend<TornMarker = unknown> {
155
155
  * Used by load (truncate + synthetic closers) and by live-adoption (truncate
156
156
  * only, `closers = []`).
157
157
  */
158
- commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>;
158
+ commitRepair(storage: SessionStorageMetadata, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>;
159
159
  /**
160
160
  * List all stored (materialized) sessions' metadata.
161
161
  * @param signal - optional cancellation for backend listing work.
@@ -210,8 +210,10 @@ export declare class PersistenceCoordinator<TornMarker = unknown> {
210
210
  /**
211
211
  * Register detached session metadata for lazy creation on the first append.
212
212
  * @param meta - header to snapshot; duplicate tracked or persisted ids reject.
213
+ * @param inheritedEventCount - exact inherited prefix length; required for
214
+ * a seeded header and omitted only for an unseeded header.
213
215
  */
214
- create(meta: SessionHeader): Promise<void>;
216
+ create(meta: SessionHeader, inheritedEventCount?: SessionLogOffsetType): Promise<void>;
215
217
  /**
216
218
  * Materialize one exact live session without inventing a session event.
217
219
  * @param session - live session already registered through the write path.
@@ -271,12 +273,9 @@ export declare class PersistenceCoordinator<TornMarker = unknown> {
271
273
  * @param id - persisted session to read.
272
274
  * @param fromSeq - first event seq to include; a non-negative safe integer.
273
275
  * @param signal - optional cancellation for queued and backend read work.
274
- * @returns stored header and the valid stored events with `seq >= fromSeq`.
276
+ * @returns stored metadata, the requested offset, and valid events with `seq >= fromSeq`.
275
277
  */
276
- readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{
277
- meta: SessionHeader;
278
- events: SessionEvent[];
279
- }>;
278
+ readFrom(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise<SessionEventSuffix>;
280
279
  private readFromCore;
281
280
  /** Read one detached physical prefix without logical recovery or caching. */
282
281
  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
@@ -125,6 +137,8 @@ export declare abstract class SessionPersistence extends Service {
125
137
  * seq contracts: the first event's `seq` MUST equal the stored next-seq
126
138
  * (after `load` has durably closed any interrupted turn). Rejects non-JSON-
127
139
  * serializable `event.data` with an error naming the offending event type.
140
+ * A seeded session's first materializing batch must reach its complete
141
+ * inherited prefix.
128
142
  * @param id - the session the batch belongs to.
129
143
  * @param events - the contiguous batch to persist, in seq order.
130
144
  */
@@ -194,14 +208,11 @@ export declare abstract class SessionPersistence extends Service {
194
208
  * forward. The primitive bounds what is returned and refolded, not every
195
209
  * backend's physical read.
196
210
  * @param id - the persisted session to read.
197
- * @param fromSeq - first event seq to include; a non-negative safe integer.
211
+ * @param fromSeq - first event offset to include.
198
212
  * @param signal - optional cancellation for queued and backend read work.
199
- * @returns the header and the stored events with `seq >= fromSeq`.
213
+ * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`.
200
214
  */
201
- abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{
202
- meta: SessionHeader;
203
- events: SessionEvent[];
204
- }>;
215
+ abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise<SessionEventSuffix>;
205
216
  /**
206
217
  * Lightweight listing from metadata, without a full-log parse.
207
218
  * @param signal - optional cancellation for backend listing work.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-session-persistence",
3
3
  "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness",
4
- "version": "0.1.2-alpha.3",
4
+ "version": "0.1.2-alpha.5",
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": {
30
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.5",
31
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
35
32
  "@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"
33
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.5"
40
34
  },
41
35
  "devDependencies": {
36
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.5",
37
+ "@deepseek-ai/dsh-scope": "^0.1.2-alpha.5",
42
38
  "@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"
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