@deepseek-ai/dsh-session-persistence-jsonl 0.1.2-alpha.5 → 0.1.3-alpha.2

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/lib/index.js CHANGED
@@ -1,15 +1,741 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
+ import { SessionFormatUnsupportedMigrationError, sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
2
3
  import { readdirSync } from "node:fs";
3
- import { link, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, stat, truncate } from "node:fs/promises";
4
- import { dirname, join, parse, resolve, toNamespacedPath } from "node:path";
4
+ import { link, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, stat, truncate } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, parse, resolve, toNamespacedPath } from "node:path";
5
6
  import { performance } from "node:perf_hooks";
6
7
  import { scheduler } from "node:timers/promises";
7
- import { randomBytes } from "node:crypto";
8
- import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, SessionFormatUnsupportedError, SessionPersistence, SessionPersistenceRevision, sessionFormatVersionRefusal } from "@deepseek-ai/dsh-session-persistence";
9
- import { SESSION_FORMAT_VERSION, SessionLogOffset, decodeSeqRanges, decodeStorageRecord, encodeSeqRanges, packChunkRuns } from "@deepseek-ai/dsh-session";
10
- import { constants, createZstdDecompress, zstdCompress, zstdDecompress, zstdDecompressSync } from "node:zlib";
11
- import { promisify } from "node:util";
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionFormatUnsupportedError, SessionHandleClosedError, SessionPersistence, SessionPersistenceCorruptionError, SessionPersistenceNotFoundError, SessionPersistenceRevision, SessionReadOnlyError, assertContiguous, assertStoredId, materializeAppendBatch, materializeCreateHeader, sessionFormatVersionRefusal, validateStoredEvents } from "@deepseek-ai/dsh-session-persistence";
10
+ import { BlockAssembler, errorChain, expandAssistantStream } from "@deepseek-ai/dsh-llm";
11
+ import { flock } from "fs-ext";
12
+ import { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset } from "@deepseek-ai/dsh-session";
13
+ import { parseSessionFormatLogFilename, sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
14
+ import { constants, createZstdCompress, createZstdDecompress, zstdCompress, zstdDecompress, zstdDecompressSync } from "node:zlib";
15
+ import { isDeepStrictEqual, promisify } from "node:util";
12
16
  import { constants as constants$1 } from "node:buffer";
17
+ import { Worker } from "node:worker_threads";
18
+ import { Readable, pipeline } from "node:stream";
19
+ /**
20
+ * The JSONL session handle. Mutations serialize on a per-handle promise
21
+ * chain; reads re-scan the artifact on demand and never observe a shorter log
22
+ * than a prior read on this handle. Routed live events buffer in a bounded
23
+ * window and drain through the same chain as explicit appends.
24
+ */
25
+ var JsonlSessionHandle = class {
26
+ storage;
27
+ id;
28
+ header;
29
+ access;
30
+ state;
31
+ lease;
32
+ chain = Promise.resolve();
33
+ closing;
34
+ observedLength = 0;
35
+ /** Routed live events awaiting their batching deadline (persistence-owned copies). */
36
+ buffered = [];
37
+ batchTimer;
38
+ /** Set when a drain failed; the automatic timer stays quiet until the next drain. */
39
+ drainPaused = false;
40
+ draining;
41
+ constructor(storage, id, header, access, state, lease) {
42
+ this.storage = storage;
43
+ this.id = id;
44
+ this.header = header;
45
+ this.access = access;
46
+ this.state = state;
47
+ this.lease = lease;
48
+ }
49
+ /** Exact fork-inherited prefix length stored with this session's log. */
50
+ get inheritedEventCount() {
51
+ return this.state.inheritedEventCount;
52
+ }
53
+ /**
54
+ * Read a slice of the valid contiguous logical log; see the seam contract.
55
+ * @param offset - first logical seq to include (default 0).
56
+ * @param length - maximum events returned (default: the rest).
57
+ * @param options - optional cancellation.
58
+ * @returns a slice carrying the aliasing state established by its producer.
59
+ */
60
+ async read(offset = 0, length = Number.MAX_SAFE_INTEGER, options) {
61
+ this.assertOpen("read");
62
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new TypeError(`read offset must be a non-negative safe integer, got ${String(offset)}`);
63
+ if (!Number.isSafeInteger(length) || length < 0) throw new TypeError(`read length must be a non-negative safe integer, got ${String(length)}`);
64
+ options?.signal?.throwIfAborted();
65
+ let result;
66
+ const primed = this.state.primed;
67
+ if (primed !== void 0) if (this.access === "write") result = this.readPrimed(primed, offset, length);
68
+ else {
69
+ const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal);
70
+ if (currentPath === void 0) result = this.readPrimed(primed, offset, length);
71
+ else {
72
+ this.state.primed = void 0;
73
+ result = await this.readCurrent(currentPath, offset, length, options?.signal);
74
+ }
75
+ }
76
+ else if (this.access === "write" && !this.state.materialized) result = {
77
+ eventState: "detached",
78
+ events: []
79
+ };
80
+ else {
81
+ const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal);
82
+ if (currentPath !== void 0) result = await this.readCurrent(currentPath, offset, length, options?.signal);
83
+ else if (this.storage.hasPendingSession(this.id)) result = {
84
+ eventState: "detached",
85
+ events: []
86
+ };
87
+ else throw new SessionPersistenceNotFoundError(this.id);
88
+ }
89
+ return result;
90
+ }
91
+ /** Read one slice from the prepared historical prefix retained by this handle. */
92
+ readPrimed(source, offset, length) {
93
+ this.observedLength = Math.max(this.observedLength, source.events.length);
94
+ return {
95
+ eventState: source.eventState,
96
+ events: source.events.slice(offset, offset + length)
97
+ };
98
+ }
99
+ /** Read one current physical generation and enforce this handle's monotonic view. */
100
+ async readCurrent(path, offset, length, signal) {
101
+ const source = await this.storage.readStoredLog(path, this.id, signal);
102
+ if (source.events.length < this.observedLength) throw new Error(`session "${this.id}": stored log shrank below a previously observed prefix (${source.events.length} < ${this.observedLength})`);
103
+ this.observedLength = source.events.length;
104
+ return {
105
+ eventState: source.eventState,
106
+ events: source.events.slice(offset, offset + length)
107
+ };
108
+ }
109
+ /**
110
+ * Durably append a contiguous batch; see the seam contract.
111
+ * @param events - the contiguous batch in seq order.
112
+ * @param options - optional cancellation observed before the write starts.
113
+ */
114
+ async append(events, options) {
115
+ this.assertOpen("append");
116
+ const batch = materializeAppendBatch(events);
117
+ return this.run("append", async () => {
118
+ options?.signal?.throwIfAborted();
119
+ await this.persistContiguous(batch);
120
+ });
121
+ }
122
+ /**
123
+ * Durability barrier; materializes the artifact when nothing has been
124
+ * appended yet, so an explicitly flushed empty session survives this process.
125
+ * @param options - optional cancellation observed before the barrier starts.
126
+ */
127
+ flush(options) {
128
+ return this.run("flush", async () => {
129
+ options?.signal?.throwIfAborted();
130
+ if (this.access !== "write") throw new SessionReadOnlyError(this.id, "flush");
131
+ if (this.state.materialized) return;
132
+ await this.ensureLease();
133
+ await this.storage.persistHeader(this.header, this.state.inheritedEventCount);
134
+ this.state.materialized = true;
135
+ });
136
+ }
137
+ /**
138
+ * Release the handle; see the seam contract. Idempotent and uncancellable.
139
+ * A write handle first drains its routed live buffer through the still-open
140
+ * storage, so backend teardown loses nothing regardless of which fiber
141
+ * unwinds first; a drain or lock-release failure still frees the in-process
142
+ * claim, then rejects — both failures together reject as one
143
+ * `AggregateError`.
144
+ * @returns settlement of the release.
145
+ */
146
+ close() {
147
+ return this.closing ??= (async () => {
148
+ let drainFailure;
149
+ for (;;) {
150
+ try {
151
+ await this.drainLive();
152
+ } catch (error) {
153
+ drainFailure = error;
154
+ break;
155
+ }
156
+ await this.chain;
157
+ if (this.buffered.length === 0) break;
158
+ }
159
+ await this.chain;
160
+ const failures = [];
161
+ if (drainFailure !== void 0) failures.push(drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure)));
162
+ try {
163
+ await this.lease?.release();
164
+ } catch (releaseFailure) {
165
+ /* v8 ignore next -- lock releases reject with Error */
166
+ failures.push(releaseFailure instanceof Error ? releaseFailure : new Error(errorChain(releaseFailure)));
167
+ }
168
+ this.storage.releaseHandle(this, this.state.materialized);
169
+ if (failures.length > 1) throw new AggregateError(failures, `session "${this.id}": close failed to drain and to release its write lock`);
170
+ if (failures[0] !== void 0) throw failures[0];
171
+ })();
172
+ }
173
+ /** `await using` support: delegates to {@link close}. */
174
+ [Symbol.asyncDispose]() {
175
+ return this.close();
176
+ }
177
+ /**
178
+ * Buffer one published live session event and arm the bounded batching
179
+ * window when it is idle. The routing installer is the only caller.
180
+ * @param event - the live event, retained as a persistence-owned copy.
181
+ * @param reportBackgroundFailure - observes a deadline-driven drain failure
182
+ * (the events stay buffered; the next {@link drainLive} retries loudly).
183
+ */
184
+ enqueueLive(event, reportBackgroundFailure) {
185
+ this.buffered.push(structuredClone(event));
186
+ if (this.batchTimer !== void 0 || this.drainPaused) return;
187
+ this.batchTimer = setTimeout(() => {
188
+ this.batchTimer = void 0;
189
+ this.drainLive().catch(reportBackgroundFailure);
190
+ }, 200);
191
+ }
192
+ /**
193
+ * Durably drain the routed live buffer through the mutation chain;
194
+ * concurrent callers join one drain, and a failure retains the batch in
195
+ * order so `session/flush` can retry and reject loudly.
196
+ */
197
+ drainLive() {
198
+ return this.draining ??= this.drainBuffered().finally(() => {
199
+ this.draining = void 0;
200
+ });
201
+ }
202
+ async drainBuffered() {
203
+ if (this.batchTimer !== void 0) {
204
+ clearTimeout(this.batchTimer);
205
+ this.batchTimer = void 0;
206
+ }
207
+ this.drainPaused = false;
208
+ while (this.buffered.length > 0) await this.enqueueChain(async () => {
209
+ const batch = this.buffered.splice(0);
210
+ try {
211
+ await this.persistContiguous(materializeAppendBatch(batch));
212
+ } catch (error) {
213
+ this.buffered = batch.concat(this.buffered);
214
+ this.drainPaused = true;
215
+ throw error;
216
+ }
217
+ });
218
+ }
219
+ /** The shared durable-append body: contiguity, ownership, torn-tail repair, storage write, state advance. */
220
+ async persistContiguous(batch) {
221
+ if (this.access !== "write") throw new SessionReadOnlyError(this.id, "append");
222
+ if (batch.length === 0) return;
223
+ await this.ensureLease();
224
+ assertContiguous(this.id, batch, this.state.cursor);
225
+ if (this.state.tornTruncateTo !== void 0) {
226
+ await this.storage.truncateTornTail(this.header, this.state.tornTruncateTo);
227
+ this.state.tornTruncateTo = void 0;
228
+ }
229
+ if (this.state.recoveredTail !== void 0) {
230
+ if (this.state.recoveredTail.length > 0) await this.storage.persistBatch(this.header, this.state.recoveredTail, this.state.materialized, this.state.inheritedEventCount);
231
+ this.state.recoveredTail = void 0;
232
+ }
233
+ await this.storage.persistBatch(this.header, batch, this.state.materialized, this.state.inheritedEventCount);
234
+ this.state.materialized = true;
235
+ this.state.cursor += batch.length;
236
+ this.state.primed = void 0;
237
+ this.observedLength = this.state.cursor;
238
+ }
239
+ /**
240
+ * Hold the cross-process write lock before this session's first durable
241
+ * write. An open write handle holds it from construction; a create handle
242
+ * acquires it here — immediately before the first log bytes publish — and
243
+ * keeps it through close even when materialization then fails, so a
244
+ * materializing session stays exclusively owned across retries.
245
+ */
246
+ async ensureLease() {
247
+ this.lease ??= await this.storage.acquireWriteLease(this.header);
248
+ }
249
+ /** Serialize one operation onto the chain without the closed-handle refusal (drain-from-close). */
250
+ enqueueChain(op) {
251
+ const next = this.chain.then(op);
252
+ this.chain = next.catch(() => {});
253
+ return next;
254
+ }
255
+ /** Serialize one public mutating operation onto this handle's chain. */
256
+ async run(operation, op) {
257
+ this.assertOpen(operation);
258
+ return this.enqueueChain(async () => {
259
+ this.assertOpen(operation);
260
+ return op();
261
+ });
262
+ }
263
+ assertOpen(operation) {
264
+ if (this.closing !== void 0) throw new SessionHandleClosedError(this.id, operation);
265
+ }
266
+ };
267
+ /**
268
+ * The JSONL backend's in-process bookkeeping: the single active writer per
269
+ * session id (doubling as the live event router), the open-handle set the
270
+ * teardown sweep closes, and the created-but-unmaterialized sessions this
271
+ * process can already observe.
272
+ */
273
+ var JsonlBackendTracker = class {
274
+ name;
275
+ /** Every open handle; teardown closes what remains. */
276
+ openHandles = /* @__PURE__ */ new Set();
277
+ /** `null` marks a claim whose handle is still being constructed. */
278
+ writers = /* @__PURE__ */ new Map();
279
+ pending = /* @__PURE__ */ new Map();
280
+ counter = 0;
281
+ /** @param name - backend label used in in-memory revision tokens and teardown errors. */
282
+ constructor(name) {
283
+ this.name = name;
284
+ }
285
+ /**
286
+ * Claim write ownership and record the created session as pending, making
287
+ * it observable to this process before it materializes. Before
288
+ * materialization this registration is the only guard — session ids do not
289
+ * collide across processes, and no durable artifact exists for another
290
+ * process to open; the handle takes the cross-process lock at its first
291
+ * materializing write.
292
+ * @param header - the validated detached header.
293
+ * @param inheritedEventCount - the exact fork-inherited prefix length.
294
+ * @throws {SessionAlreadyExistsError} when a concurrent create or an open
295
+ * write handle holds the id — for create, the duplicate is the fact.
296
+ */
297
+ registerCreated(header, inheritedEventCount) {
298
+ if (this.writers.has(header.id)) throw new SessionAlreadyExistsError(header.id);
299
+ this.writers.set(header.id, null);
300
+ this.pending.set(header.id, {
301
+ header,
302
+ revision: SessionPersistenceRevision(`memory:${this.name}:${++this.counter}`),
303
+ inheritedEventCount
304
+ });
305
+ }
306
+ /**
307
+ * Claim write ownership for an existing session.
308
+ * @param id - the session to claim.
309
+ * @throws {SessionAlreadyOwnedError} when an active write handle exists.
310
+ */
311
+ claimWrite(id) {
312
+ if (this.writers.has(id)) throw new SessionAlreadyOwnedError(id);
313
+ this.writers.set(id, null);
314
+ }
315
+ /**
316
+ * Roll a failed write open back.
317
+ * @param id - the session whose claim is dropped.
318
+ */
319
+ releaseClaim(id) {
320
+ this.writers.delete(id);
321
+ }
322
+ /**
323
+ * The pending entry for a created-but-unmaterialized session, if any.
324
+ * @param id - the session to look up.
325
+ * @returns the pending header and in-memory revision.
326
+ */
327
+ pendingOf(id) {
328
+ return this.pending.get(id);
329
+ }
330
+ /**
331
+ * Whether this process still tracks a created-but-unmaterialized session.
332
+ * @param id - the session to test.
333
+ * @returns true while the pending entry exists.
334
+ */
335
+ hasPending(id) {
336
+ return this.pending.has(id);
337
+ }
338
+ /**
339
+ * Iterate the pending sessions for listing.
340
+ * @returns the pending entries, keyed by session id.
341
+ */
342
+ pendingEntries() {
343
+ return this.pending.entries();
344
+ }
345
+ /**
346
+ * Drop a pending entry once the session materialized durably.
347
+ * @param id - the session that reached durable storage.
348
+ */
349
+ materialized(id) {
350
+ this.pending.delete(id);
351
+ }
352
+ /**
353
+ * Track one open handle for teardown and, for a write handle, bind it as
354
+ * the session's live event route.
355
+ * @param handle - the just-constructed handle.
356
+ * @returns the same handle, for construction-site chaining.
357
+ */
358
+ adopt(handle) {
359
+ this.openHandles.add(handle);
360
+ if (handle.access === "write") this.writers.set(handle.id, handle);
361
+ return handle;
362
+ }
363
+ /**
364
+ * Release one handle's bookkeeping on close. A write handle drops its
365
+ * ownership claim; a creator that never materialized leaves nothing behind —
366
+ * the session never existed.
367
+ * @param handle - the closing handle.
368
+ * @param materialized - whether the session reached durable storage.
369
+ */
370
+ release(handle, materialized) {
371
+ this.openHandles.delete(handle);
372
+ if (handle.access !== "write") return;
373
+ this.writers.delete(handle.id);
374
+ if (!materialized) this.pending.delete(handle.id);
375
+ }
376
+ /**
377
+ * Drain and flush every active write handle — the service-wide durability
378
+ * barrier behind `SessionPersistence.flush`.
379
+ * @throws {AggregateError} naming each session whose flush failed; the
380
+ * remaining handles still flush.
381
+ */
382
+ async flushAll() {
383
+ const errors = [];
384
+ for (const writer of [...this.writers.values()]) {
385
+ if (writer === null) continue;
386
+ try {
387
+ await writer.drainLive();
388
+ await writer.flush();
389
+ } catch (error) {
390
+ if (error instanceof SessionHandleClosedError) continue;
391
+ errors.push(error);
392
+ }
393
+ }
394
+ if (errors.length > 0) throw new AggregateError(errors, `${this.name} flush failed`);
395
+ }
396
+ /**
397
+ * Install the backend's live session routing and teardown. Persistence
398
+ * enforces one active write handle per id, so the listeners route published
399
+ * sessions' events by id; the teardown effect closes every open handle —
400
+ * close drains the routed buffer — and aggregates failures. This provider
401
+ * owns no separate storage connection, so closing handles is the complete
402
+ * teardown. Registrations are effects of the current fiber.
403
+ * @param ctx - the backend's context.
404
+ */
405
+ install(ctx) {
406
+ ctx.on("session/event", (session, event) => {
407
+ this.writers.get(session.id)?.enqueueLive(event, (error) => {
408
+ ctx.logger.warn(`session-persistence: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`);
409
+ });
410
+ });
411
+ ctx.on("session/flush", (session) => {
412
+ const writer = this.writers.get(session.id);
413
+ if (writer === null || writer === void 0) return void 0;
414
+ return (async () => {
415
+ await writer.drainLive();
416
+ await writer.flush();
417
+ })();
418
+ });
419
+ ctx.on("session/disposed", (session) => {
420
+ const writer = this.writers.get(session.id);
421
+ if (writer === null || writer === void 0) return;
422
+ writer.close().catch((error) => {
423
+ ctx.logger.warn(`session-persistence: final drain for session "${session.id}" failed: ${String(error)}`);
424
+ });
425
+ });
426
+ ctx.effect(() => async () => {
427
+ const errors = [];
428
+ for (const handle of [...this.openHandles]) try {
429
+ await handle.close();
430
+ } catch (error) {
431
+ errors.push(error);
432
+ }
433
+ if (errors.length > 0) throw new AggregateError(errors, `${this.name} dispose failed`);
434
+ }, `${this.name} open handles`);
435
+ }
436
+ };
437
+ //#endregion
438
+ //#region lib/types/win32.js
439
+ /**
440
+ * Windows durable namespace helpers for the JSONL backend.
441
+ *
442
+ * POSIX publishes a newly-created log by creating a directory entry and then
443
+ * fsyncing the parent directory. Windows does not expose that parent-directory
444
+ * fsync contract through Node, so the Windows path uses the native durable
445
+ * namespace primitive instead: create a staging object in the target directory
446
+ * and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
447
+ * replacement or cross-volume copy fallback.
448
+ *
449
+ * @module dsh-session-persistence-jsonl/win32
450
+ */
451
+ const MOVEFILE_WRITE_THROUGH = 8;
452
+ const WAIT_OBJECT_0 = 0;
453
+ const WAIT_TIMEOUT = 258;
454
+ const ERROR_FILE_NOT_FOUND = 2;
455
+ const ERROR_PATH_NOT_FOUND = 3;
456
+ const ERROR_ACCESS_DENIED = 5;
457
+ const ERROR_NOT_SAME_DEVICE = 17;
458
+ const ERROR_SHARING_VIOLATION = 32;
459
+ const ERROR_FILE_EXISTS = 80;
460
+ const ERROR_INVALID_NAME = 123;
461
+ const ERROR_ALREADY_EXISTS = 183;
462
+ let bindings;
463
+ /** Load the small Win32 API lazily so non-Windows processes never load Koffi. */
464
+ async function win32() {
465
+ if (bindings !== void 0) return bindings;
466
+ const kernel32 = (await import("koffi")).default.load("kernel32.dll");
467
+ bindings = {
468
+ moveFileExW: kernel32.func("__stdcall", "MoveFileExW", "int", [
469
+ "str16",
470
+ "str16",
471
+ "uint"
472
+ ]),
473
+ createSemaphoreW: kernel32.func("__stdcall", "CreateSemaphoreW", "intptr", [
474
+ "void*",
475
+ "int",
476
+ "int",
477
+ "str16"
478
+ ]),
479
+ waitForSingleObject: kernel32.func("__stdcall", "WaitForSingleObject", "uint", ["intptr", "uint"]),
480
+ releaseSemaphore: kernel32.func("__stdcall", "ReleaseSemaphore", "int", [
481
+ "intptr",
482
+ "int",
483
+ "void*"
484
+ ]),
485
+ closeHandle: kernel32.func("__stdcall", "CloseHandle", "int", ["intptr"]),
486
+ getLastError: kernel32.func("__stdcall", "GetLastError", "uint", [])
487
+ };
488
+ return bindings;
489
+ }
490
+ function errnoCode(win32Code) {
491
+ switch (win32Code) {
492
+ case ERROR_FILE_NOT_FOUND:
493
+ case ERROR_PATH_NOT_FOUND: return "ENOENT";
494
+ case ERROR_ACCESS_DENIED: return "EACCES";
495
+ case ERROR_NOT_SAME_DEVICE: return "EXDEV";
496
+ case ERROR_SHARING_VIOLATION: return "EBUSY";
497
+ case ERROR_FILE_EXISTS:
498
+ case ERROR_ALREADY_EXISTS: return "EEXIST";
499
+ case ERROR_INVALID_NAME: return "EINVAL";
500
+ default: return "EIO";
501
+ }
502
+ }
503
+ function win32Error(syscall, win32Code, path, dest) {
504
+ const code = errnoCode(win32Code);
505
+ const error = /* @__PURE__ */ new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`);
506
+ error.code = code;
507
+ error.errno = win32Code;
508
+ error.syscall = syscall;
509
+ error.path = path;
510
+ error.dest = dest;
511
+ error.win32Code = win32Code;
512
+ return error;
513
+ }
514
+ function isENOENT$1(error) {
515
+ return error?.code === "ENOENT";
516
+ }
517
+ function isEEXIST$1(error) {
518
+ return error?.code === "EEXIST";
519
+ }
520
+ async function assertDirectory(path) {
521
+ try {
522
+ if ((await stat(path === parse(path).root ? path : toNamespacedPath(path))).isDirectory()) return true;
523
+ const error = /* @__PURE__ */ new Error(`path exists but is not a directory: ${path}`);
524
+ error.code = "ENOTDIR";
525
+ error.path = path;
526
+ throw error;
527
+ } catch (error) {
528
+ if (isENOENT$1(error)) return false;
529
+ throw error;
530
+ }
531
+ }
532
+ /**
533
+ * Publish `existing` at `replacement` with Windows write-through rename
534
+ * semantics. The destination must not already exist; the move must stay within
535
+ * the volume (no copy fallback flag is set).
536
+ * @param existing - the synced staging path to move.
537
+ * @param replacement - the final path, which must not already exist.
538
+ */
539
+ async function publishNewFileWin32(existing, replacement) {
540
+ const api = await win32();
541
+ if (api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH) === 0) throw win32Error("MoveFileExW", api.getLastError(), existing, replacement);
542
+ }
543
+ /**
544
+ * Acquire the session write lock as a named kernel semaphore (count 1) whose
545
+ * name is derived from the canonical lock path. A kernel object never touches
546
+ * the filesystem, so readers, searches, and directory removal proceed freely
547
+ * while the lock is held; a second acquirer's zero-timeout wait times out
548
+ * (`EBUSY`); and when the last handle closes — including on any process
549
+ * death — the object is destroyed, so a successor's create starts fresh.
550
+ * @param path - the lock file path the name is derived from (case-folded:
551
+ * Windows paths are case-insensitive).
552
+ * @returns the open semaphore handle, released via {@link releaseLockHandleWin32}.
553
+ */
554
+ async function acquireLockHandleWin32(path) {
555
+ const api = await win32();
556
+ const name = `Local\\dsh-session-lock-${createHash("sha256").update(resolve(path).toLowerCase()).digest("hex")}`;
557
+ const handle = api.createSemaphoreW(null, 1, 1, name);
558
+ if (handle === 0) throw win32Error("CreateSemaphoreW", api.getLastError(), path, name);
559
+ const wait = api.waitForSingleObject(handle, 0);
560
+ if (wait === WAIT_OBJECT_0) return handle;
561
+ api.closeHandle(handle);
562
+ if (wait === WAIT_TIMEOUT) throw win32Error("WaitForSingleObject", ERROR_SHARING_VIOLATION, path, name);
563
+ throw win32Error("WaitForSingleObject", api.getLastError(), path, name);
564
+ }
565
+ /**
566
+ * Release a lock from {@link acquireLockHandleWin32}: restore the semaphore
567
+ * count and close the handle (the object dies with its last handle).
568
+ * @param handle - the open semaphore handle.
569
+ */
570
+ async function releaseLockHandleWin32(handle) {
571
+ const api = await win32();
572
+ const released = api.releaseSemaphore(handle, 1, null);
573
+ const closed = api.closeHandle(handle);
574
+ if (released === 0 || closed === 0) throw win32Error("ReleaseSemaphore", api.getLastError(), `handle:${handle}`, `handle:${handle}`);
575
+ }
576
+ /**
577
+ * Create `target` and its missing ancestors with durable Windows namespace
578
+ * publication. Each missing directory is first created as a random staging
579
+ * sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
580
+ * with another creator are accepted only after verifying the winner is a
581
+ * directory.
582
+ * @param target - the absolute directory path to create durably when absent.
583
+ */
584
+ async function ensureDurableDirectoryWin32(target) {
585
+ const absolute = resolve(target);
586
+ const root = parse(absolute).root;
587
+ await assertDirectory(root);
588
+ const segments = absolute.slice(root.length).split(/[\\/]+/).filter((part) => part.length > 0);
589
+ let current = root;
590
+ for (const segment of segments) {
591
+ const next = join(current, segment);
592
+ if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next);
593
+ current = next;
594
+ }
595
+ }
596
+ async function createLeafDirectoryWin32(parent, target) {
597
+ const staging = await mkdtemp(toNamespacedPath(join(parent, ".dsh-mkdir-")));
598
+ try {
599
+ await publishNewFileWin32(staging, target);
600
+ } catch (error) {
601
+ await rm(staging, {
602
+ recursive: true,
603
+ force: true
604
+ });
605
+ if (isEEXIST$1(error) && await assertDirectory(target)) return;
606
+ throw error;
607
+ }
608
+ }
609
+ //#endregion
610
+ //#region lib/types/lease.js
611
+ /**
612
+ * Cross-process write-ownership lock for one session's artifact directory,
613
+ * held for the whole life of a write handle. The arbiter is the kernel:
614
+ * POSIX takes a non-blocking `flock(2)` (through fs-ext) on `session.lock`
615
+ * beside the log, and Windows holds a named kernel semaphore derived from
616
+ * that path — never a file lock or handle, so readers, searches, and
617
+ * directory removal proceed freely while the lock is held. Contention maps
618
+ * to `SessionAlreadyOwnedError`; the kernel releases the lock when the
619
+ * holder's descriptor or last object handle closes, including on any process
620
+ * death, so a crashed holder never blocks a successor. A live but wedged
621
+ * holder keeps the lock until its process exits: there is deliberately no
622
+ * expiry that could expropriate a stalled writer whose resumed appends would
623
+ * tear the log.
624
+ * A POSIX lock names an inode, not a path, so after locking the holder
625
+ * verifies the locked inode is still the file at the lock path and retries
626
+ * otherwise: an unlinked-and-recreated lock file carries a fresh inode, and
627
+ * a lock on the orphaned one proves nothing. Removing a live session's lock
628
+ * file therefore forfeits exclusion on POSIX (nothing in the harness does
629
+ * so); Windows has no lock file at all. Readers never touch the lock.
630
+ * The lock is acquired at write-open of an existing artifact and, for a
631
+ * created session, only right before its first materializing write — an
632
+ * unmaterialized session has no filesystem footprint. Release never removes
633
+ * the POSIX lock file: every acquired lock belongs to a materialized or
634
+ * materializing session, and the surviving file keeps the stable inode later
635
+ * lockers verify against. The browser worker deployment stubs fs-ext to
636
+ * immediate success: it is single-process, so the in-process write claim
637
+ * already excludes every writer.
638
+ * @module @deepseek-ai/dsh-session-persistence-jsonl/lease
639
+ */
640
+ /** Base name of the kernel lock file inside a session's directory. */
641
+ const LEASE_FILENAME = "session.lock";
642
+ /** Promise face over fs-ext's callback flock, pinned to its string-flag overload. */
643
+ function flockAsync(fd, flags) {
644
+ return new Promise((resolve, reject) => {
645
+ flock(fd, flags, (error) => {
646
+ if (error) reject(error);
647
+ else resolve();
648
+ });
649
+ });
650
+ }
651
+ /** Whether a flock failure means another descriptor holds the lock. */
652
+ function isLockContention(error) {
653
+ const code = error?.code;
654
+ return code === "EAGAIN" || code === "EWOULDBLOCK";
655
+ }
656
+ /**
657
+ * One held write lock. Constructed only by {@link SessionWriteLease.acquire};
658
+ * `release` closes the descriptor or handle, which is what releases the lock.
659
+ */
660
+ var SessionWriteLease = class SessionWriteLease {
661
+ held;
662
+ released = false;
663
+ constructor(held) {
664
+ this.held = held;
665
+ }
666
+ /**
667
+ * Acquire the session directory's kernel write lock.
668
+ * @param dir - the session's artifact directory (created if absent).
669
+ * @param id - the session the lock guards, for error identities.
670
+ * @returns the held lock.
671
+ * @throws {SessionAlreadyOwnedError} while another holder keeps the lock.
672
+ */
673
+ static async acquire(dir, id) {
674
+ const path = join(dir, LEASE_FILENAME);
675
+ await mkdir(dir, {
676
+ recursive: true,
677
+ mode: 448
678
+ });
679
+ /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */
680
+ if (process.platform === "win32") {
681
+ let handle;
682
+ try {
683
+ handle = await acquireLockHandleWin32(path);
684
+ } catch (error) {
685
+ if (error?.code === "EBUSY") throw new SessionAlreadyOwnedError(id);
686
+ throw error;
687
+ }
688
+ return new SessionWriteLease({
689
+ kind: "win32",
690
+ handle
691
+ });
692
+ }
693
+ /* v8 ignore stop */
694
+ for (let attempt = 0; attempt < 3; attempt += 1) {
695
+ const handle = await open(path, "w");
696
+ try {
697
+ try {
698
+ await flockAsync(handle.fd, "exnb");
699
+ } catch (error) {
700
+ if (isLockContention(error)) throw new SessionAlreadyOwnedError(id);
701
+ throw error;
702
+ }
703
+ const held = await handle.stat({ bigint: true });
704
+ const current = await stat(path, { bigint: true }).catch((error) => {
705
+ if (error?.code === "ENOENT") return void 0;
706
+ throw error;
707
+ });
708
+ if (current !== void 0 && current.ino === held.ino && current.dev === held.dev) return new SessionWriteLease({
709
+ kind: "posix",
710
+ handle
711
+ });
712
+ } catch (error) {
713
+ await handle.close();
714
+ throw error;
715
+ }
716
+ await handle.close();
717
+ }
718
+ throw new SessionAlreadyOwnedError(id);
719
+ }
720
+ /**
721
+ * Release the kernel lock by closing its descriptor or handle. The POSIX
722
+ * lock file is never removed: every acquired lock belongs to a
723
+ * materialized or materializing session, and keeping the file preserves
724
+ * the stable inode later lockers verify against. Idempotent.
725
+ */
726
+ async release() {
727
+ if (this.released) return;
728
+ this.released = true;
729
+ /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */
730
+ if (this.held.kind === "win32") {
731
+ await releaseLockHandleWin32(this.held.handle);
732
+ return;
733
+ }
734
+ /* v8 ignore stop */
735
+ await this.held.handle.close();
736
+ }
737
+ };
738
+ //#endregion
13
739
  //#region lib/types/format.js
14
740
  /**
15
741
  * On-disk format helpers for the JSONL session-persistence backend: path
@@ -26,7 +752,58 @@ import { constants as constants$1 } from "node:buffer";
26
752
  * @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
27
753
  */
28
754
  function logSuffix(compression) {
29
- return compression === "zstd" ? ".jsonl.zstd" : ".jsonl";
755
+ return `.jsonl${compressionSuffix(compression)}`;
756
+ }
757
+ function compressionSuffix(compression) {
758
+ return compression === "zstd" ? ".zstd" : "";
759
+ }
760
+ /**
761
+ * Return the canonical filename for one immutable Session format generation.
762
+ * Version zero retains the original suffix-only name; every later generation
763
+ * carries a lowercase numeric `vN` component.
764
+ * @param version - non-negative safe Session format version.
765
+ * @param compression - configured JSONL artifact encoding.
766
+ * @returns the generation filename inside one Session directory.
767
+ */
768
+ function generationLogFilename(version, compression) {
769
+ return `${sessionFormatLogFilename(version)}${compressionSuffix(compression)}`;
770
+ }
771
+ /**
772
+ * Parse one canonical generation filename for the selected physical encoding.
773
+ * Noncanonical, temporary, uppercase, leading-zero, and version-zero-tagged names do
774
+ * not identify committed generations.
775
+ * @param filename - one entry from a Session directory.
776
+ * @param compression - configured JSONL artifact encoding.
777
+ * @returns its format version, or `undefined` when the name is not canonical.
778
+ */
779
+ function parseGenerationLogFilename(filename, compression) {
780
+ const suffix = compressionSuffix(compression);
781
+ if (!filename.endsWith(suffix)) return void 0;
782
+ return parseSessionFormatLogFilename(filename.slice(0, filename.length - suffix.length));
783
+ }
784
+ const HEADER_REQUIRED_KEYS = [
785
+ "type",
786
+ "version",
787
+ "id",
788
+ "createdAt",
789
+ "isSeeded",
790
+ "delegationDepth"
791
+ ];
792
+ const HEADER_OPTIONAL_KEYS = [
793
+ "cwd",
794
+ "parentSession",
795
+ "origin",
796
+ "agentPreset"
797
+ ];
798
+ const HEADER_KEYS = new Set([...HEADER_REQUIRED_KEYS, ...HEADER_OPTIONAL_KEYS]);
799
+ /**
800
+ * Refuse policy fields that never belong to a released Session header.
801
+ * @param value - parsed physical header candidate.
802
+ * @returns nothing after successful validation.
803
+ */
804
+ function assertNoRetiredHeaderFields(value) {
805
+ if (typeof value !== "object" || value === null) return;
806
+ if (Object.hasOwn(value, "sandboxMode") || Object.hasOwn(value, "approvalPolicy")) throw new Error("session header uses retired policy baseline fields");
30
807
  }
31
808
  /**
32
809
  * Build the header line object from a {@link SessionHeader}.
@@ -39,44 +816,35 @@ function toHeaderLine(header, inheritedEventCount) {
39
816
  if (header.isSeeded && inheritedEventCount === void 0) throw new Error("seeded session header requires an inherited event count");
40
817
  const cut = SessionLogOffset(inheritedEventCount ?? 0);
41
818
  if (!header.isSeeded && cut !== 0) throw new Error("unseeded session header inherited event count must be 0");
42
- return {
43
- type: "session",
44
- version: header.version,
45
- id: header.id,
46
- createdAt: header.createdAt,
47
- ...header.cwd !== void 0 ? { cwd: header.cwd } : {},
48
- ...header.parentSession !== void 0 ? { parentSession: header.parentSession } : {},
49
- ...header.isSeeded ? { seedLength: cut } : {},
50
- ...header.origin !== void 0 ? { origin: header.origin } : {},
51
- delegationDepth: header.delegationDepth ?? 0,
52
- ...header.agentPreset !== void 0 ? { agentPreset: header.agentPreset } : {}
53
- };
819
+ return sessionFormatCatalog.encodeCurrentHeader({
820
+ ...header,
821
+ delegationDepth: header.delegationDepth ?? 0
822
+ }, cut);
54
823
  }
55
824
  /**
56
- * Translate one version-0 physical header into logical metadata and its cut.
825
+ * Translate one current physical header into logical metadata and its cut.
57
826
  * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
58
827
  * @returns logical Session metadata paired with the exact inherited prefix length.
59
828
  */
60
829
  function fromHeaderLine(line) {
61
- if (Object.hasOwn(line, "sandboxMode") || Object.hasOwn(line, "approvalPolicy")) throw new Error("session header uses retired policy baseline fields");
62
830
  return {
63
831
  meta: {
64
- version: line.version,
832
+ version: SESSION_FORMAT_VERSION,
65
833
  id: line.id,
66
834
  createdAt: line.createdAt,
67
835
  ...line.cwd !== void 0 ? { cwd: line.cwd } : {},
68
836
  ...line.parentSession !== void 0 ? { parentSession: line.parentSession } : {},
69
- isSeeded: line.seedLength !== void 0,
837
+ isSeeded: line.isSeeded,
70
838
  ...line.origin !== void 0 ? { origin: line.origin } : {},
71
839
  delegationDepth: line.delegationDepth,
72
840
  ...line.agentPreset !== void 0 ? { agentPreset: line.agentPreset } : {}
73
841
  },
74
- inheritedEventCount: SessionLogOffset(line.seedLength ?? 0)
842
+ inheritedEventCount: SessionLogOffset(0)
75
843
  };
76
844
  }
77
845
  /** Type guard: a parsed first line is a well-formed session header. */
78
846
  function isHeaderLine(value) {
79
- return typeof value === "object" && value !== null && value.type === "session" && typeof value.version === "number" && typeof value.id === "string" && typeof value.createdAt === "number" && Number.isSafeInteger(value.createdAt) && value.createdAt >= 0 && !Object.is(value.createdAt, -0) && typeof value.delegationDepth === "number" && Number.isSafeInteger(value.delegationDepth) && value.delegationDepth >= 0 && !Object.is(value.delegationDepth, -0) && (value.seedLength === void 0 || typeof value.seedLength === "number" && Number.isSafeInteger(value.seedLength) && value.seedLength >= 0 && !Object.is(value.seedLength, -0)) && (value.origin === void 0 || value.origin === "subagent") && (value.agentPreset === void 0 || typeof value.agentPreset === "string");
847
+ return typeof value === "object" && value !== null && !Array.isArray(value) && HEADER_REQUIRED_KEYS.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => HEADER_KEYS.has(key)) && value.type === "session" && typeof value.version === "number" && typeof value.id === "string" && typeof value.createdAt === "number" && Number.isSafeInteger(value.createdAt) && value.createdAt >= 0 && !Object.is(value.createdAt, -0) && typeof value.delegationDepth === "number" && Number.isSafeInteger(value.delegationDepth) && value.delegationDepth >= 0 && !Object.is(value.delegationDepth, -0) && (value.cwd === void 0 || typeof value.cwd === "string" && isAbsolute(value.cwd)) && (value.parentSession === void 0 || typeof value.parentSession === "string") && typeof value.isSeeded === "boolean" && (value.origin === void 0 || value.origin === "subagent") && (value.agentPreset === void 0 || typeof value.agentPreset === "string");
80
848
  }
81
849
  /**
82
850
  * Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16)
@@ -154,63 +922,46 @@ function sessionDir(root, cwd, id) {
154
922
  return join(projectDir(root, cwd), encodeSegment(id));
155
923
  }
156
924
  /**
157
- * The append-only event-log file path for a session.
925
+ * Build one immutable Session format generation path.
926
+ * @param root - the backend's session root directory.
927
+ * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
928
+ * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
929
+ * @param version - physical Session format generation.
930
+ * @param compression - physical artifact encoding and filename suffix.
931
+ * @returns the selected generation's configured JSONL artifact path.
932
+ */
933
+ function generationLogPath(root, cwd, id, version, compression) {
934
+ return join(sessionDir(root, cwd, id), generationLogFilename(version, compression));
935
+ }
936
+ /**
937
+ * Build the current generation's append target path for a Session.
158
938
  * @param root - the backend's session root directory.
159
939
  * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
160
940
  * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
161
941
  * @param compression - physical artifact encoding and filename suffix.
162
- * @returns the session's configured JSONL artifact path.
942
+ * @returns the current Session format generation path.
163
943
  */
164
944
  function logPath(root, cwd, id, compression) {
165
- return join(sessionDir(root, cwd, id), `session${logSuffix(compression)}`);
945
+ return generationLogPath(root, cwd, id, SESSION_FORMAT_VERSION, compression);
166
946
  }
167
947
  /**
168
- * Serialize an event batch as JSONL lines (no trailing newline). With
169
- * `packChunks` on, delta-chunk runs pack into `text-chunks` /
170
- * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event
171
- * per line. Both modes range-encode provenance at the storage boundary.
172
- * Reading is layout-blind either way ({@link scanLog} always decodes rows),
173
- * so the switch changes only newly written bytes.
948
+ * Serialize a v2 event batch as JSONL lines (no trailing newline). Compact
949
+ * Assistant streams are nested event data; every event occupies one row.
174
950
  * @param events - the batch to serialize, in log order.
175
- * @param packChunks - whether to pack delta runs into storage rows.
176
951
  * @returns the batch's JSONL text; the writer adds the final newline.
177
952
  */
178
- function eventLines(events, packChunks) {
179
- return (packChunks ? packChunkRuns(events) : events).map((record) => JSON.stringify(encodeProvenanceForStorage(record))).join("\n");
953
+ function eventLines(events) {
954
+ return events.map(eventLine).join("\n");
180
955
  }
181
956
  /**
182
- * Losslessly shrink a record's `sourceEventSeqs` for the log: consecutive
183
- * runs of at least three seqs become `[start, end]` pairs, and any other list
184
- * stays verbatim.
185
- * @param record - one stored record (event or packed row).
186
- * @returns the record with its provenance in storage form (widened from the
187
- * in-memory `SessionSeq[]`; {@link expandProvenanceFromStorage} restores it).
957
+ * Serialize one v2 event as one JSONL record without its trailing newline.
958
+ * @param event - current event to encode.
959
+ * @returns one physical JSON record.
188
960
  */
189
- function encodeProvenanceForStorage(record) {
190
- if (!("sourceEventSeqs" in record)) return record;
191
- return {
192
- ...record,
193
- sourceEventSeqs: encodeSeqRanges(record.sourceEventSeqs)
194
- };
961
+ function eventLine(event) {
962
+ return JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event));
195
963
  }
196
964
  /**
197
- * Expand a parsed line's storage-form provenance back to `SessionSeq[]`.
198
- * @param parsed - the JSON-parsed value of one stored line.
199
- * @returns the value with provenance expanded.
200
- * @throws when the record or its storage-form provenance is malformed.
201
- */
202
- function expandProvenanceFromStorage(parsed) {
203
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new TypeError("stored session records must be objects");
204
- const record = parsed;
205
- if (record.sourceEventSeqs === void 0) return parsed;
206
- if (!Number.isSafeInteger(record.seq) || record.seq < 0) throw new TypeError("stored session event seq must be a non-negative safe integer");
207
- return {
208
- ...record,
209
- sourceEventSeqs: decodeSeqRanges(record.sourceEventSeqs, record.seq)
210
- };
211
- }
212
- /** Parse one complete header record supplied independently from event rows. */
213
- /**
214
965
  * Refuse a header carrying a format version this build does not read BEFORE
215
966
  * validating the current header shape or decoding any event row: a future
216
967
  * format need not satisfy this build's structural checks at all, and its user
@@ -218,11 +969,11 @@ function expandProvenanceFromStorage(parsed) {
218
969
  * @param parsed - the JSON-parsed first line of a session artifact.
219
970
  */
220
971
  function refuseForeignFormatVersion(parsed) {
221
- if (typeof parsed !== "object" || parsed === null) return;
222
972
  const { version, id } = parsed;
223
973
  if (typeof version !== "number" || version === SESSION_FORMAT_VERSION) return;
224
974
  throw new SessionFormatUnsupportedError(sessionFormatVersionRefusal(typeof id === "string" ? id : String(id), version));
225
975
  }
976
+ /** Parse one complete header record supplied independently from event rows. */
226
977
  function parseHeaderRecord(record) {
227
978
  if (record.length === 0 || record.at(-1) !== 10 || record.indexOf(10) !== record.length - 1) throw new Error("empty or header-less session log");
228
979
  let parsed;
@@ -231,9 +982,24 @@ function parseHeaderRecord(record) {
231
982
  } catch {
232
983
  throw new Error("corrupt session log: header line is not valid JSON");
233
984
  }
985
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("corrupt session log: first line is not a JSON object");
234
986
  refuseForeignFormatVersion(parsed);
987
+ assertNoRetiredHeaderFields(parsed);
235
988
  if (!isHeaderLine(parsed)) throw new Error("corrupt session log: first line is not a session header");
236
- return fromHeaderLine(parsed);
989
+ let restore;
990
+ try {
991
+ restore = sessionFormatCatalog.createRestore(parsed, {
992
+ recovery: "strict",
993
+ validation: "transformed"
994
+ });
995
+ } catch {
996
+ /* v8 ignore next -- isHeaderLine matches the current codec; this preserves classification if it tightens. */
997
+ throw new Error("corrupt session log: first line is not a session header");
998
+ }
999
+ return {
1000
+ meta: fromHeaderLine(parsed).meta,
1001
+ restore
1002
+ };
237
1003
  }
238
1004
  /**
239
1005
  * Incrementally scan complete JSONL event records after an independently
@@ -242,9 +1008,10 @@ function parseHeaderRecord(record) {
242
1008
  * copied because a decoder may reuse its output buffer after `write()` returns.
243
1009
  */
244
1010
  var SessionLogScanner = class {
1011
+ recovery;
245
1012
  meta;
246
- inheritedEventCount;
247
- events = [];
1013
+ restore;
1014
+ eventCount = 0;
248
1015
  fragments = [];
249
1016
  fragmentBytes = 0;
250
1017
  inputBytes;
@@ -256,10 +1023,11 @@ var SessionLogScanner = class {
256
1023
  * Create an event scanner from exactly one newline-terminated header record.
257
1024
  * @param headerRecord - the complete first JSONL record, including its newline.
258
1025
  */
259
- constructor(headerRecord) {
1026
+ constructor(headerRecord, recovery = "recoverable") {
1027
+ this.recovery = recovery;
260
1028
  const parsed = parseHeaderRecord(headerRecord);
261
1029
  this.meta = parsed.meta;
262
- this.inheritedEventCount = parsed.inheritedEventCount;
1030
+ this.restore = parsed.restore;
263
1031
  this.inputBytes = headerRecord.length;
264
1032
  this.committedBytes = headerRecord.length;
265
1033
  }
@@ -298,7 +1066,7 @@ var SessionLogScanner = class {
298
1066
  return {
299
1067
  inputBytes: this.inputBytes,
300
1068
  committedBytes: this.committedBytes,
301
- eventCount: SessionLogOffset(this.events.length)
1069
+ eventCount: SessionLogOffset(this.eventCount)
302
1070
  };
303
1071
  }
304
1072
  /**
@@ -307,10 +1075,11 @@ var SessionLogScanner = class {
307
1075
  */
308
1076
  finish() {
309
1077
  this.finished = true;
1078
+ const artifact = this.restore.finish();
310
1079
  return {
311
1080
  meta: this.meta,
312
- inheritedEventCount: this.inheritedEventCount,
313
- events: this.events,
1081
+ inheritedEventCount: SessionLogOffset(artifact.inheritedEventCount),
1082
+ events: artifact.events,
314
1083
  committedBytes: this.committedBytes
315
1084
  };
316
1085
  }
@@ -319,26 +1088,29 @@ var SessionLogScanner = class {
319
1088
  this.eventLine += 1;
320
1089
  let decoded;
321
1090
  try {
322
- decoded = decodeStorageRecord(expandProvenanceFromStorage(JSON.parse(line.toString("utf8"))));
1091
+ decoded = JSON.parse(line.toString("utf8"));
323
1092
  } catch {
324
- this.issue ??= /* @__PURE__ */ new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`);
1093
+ const issue = /* @__PURE__ */ new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`);
1094
+ if (this.recovery === "strict") throw issue;
1095
+ this.issue ??= issue;
325
1096
  return;
326
1097
  }
327
1098
  if (this.issue !== void 0) {
328
- if (decoded.some((event) => event.type === "turn/end")) throw this.issue;
1099
+ if (typeof decoded === "object" && decoded !== null && decoded.type === "turn/end") throw this.issue;
329
1100
  return;
330
1101
  }
331
- const rowStart = this.events.length;
332
- for (const event of decoded) {
333
- if (event.seq !== this.events.length) {
334
- const expected = this.events.length;
335
- this.events.length = rowStart;
336
- this.issue = /* @__PURE__ */ new Error(`corrupt session log: seq gap in committed region at line ${this.eventLine} (expected ${expected}, got ${event.seq})`);
337
- if (decoded.some((candidate) => candidate.type === "turn/end")) throw this.issue;
338
- return;
339
- }
340
- this.events.push(event);
1102
+ try {
1103
+ this.restore.decodeRow(decoded);
1104
+ } catch (error) {
1105
+ /* v8 ignore next -- every production Session format decoder rejects with Error. */
1106
+ const detail = error instanceof Error ? error.message : String(error);
1107
+ const issue = new Error(`corrupt session log: invalid committed event at line ${this.eventLine}: ${detail}`, { cause: error });
1108
+ if (this.recovery === "strict") throw issue;
1109
+ this.issue = issue;
1110
+ if (typeof decoded === "object" && decoded !== null && decoded.type === "turn/end") throw issue;
1111
+ return;
341
1112
  }
1113
+ this.eventCount += 1;
342
1114
  this.committedBytes = endByte;
343
1115
  }
344
1116
  };
@@ -357,31 +1129,6 @@ function scanLog(buffer) {
357
1129
  scanner.write(buffer.subarray(headerEnd + 1));
358
1130
  return scanner.finish();
359
1131
  }
360
- /**
361
- * Parse just the header line of a log into logical metadata plus its exact
362
- * inherited cut, or `undefined` if it is missing/not a header.
363
- * @param firstLine - the first line of a log file (without its trailing newline).
364
- * @returns parsed storage metadata, or `undefined` for a malformed header.
365
- */
366
- function parseHeader(firstLine) {
367
- let parsed;
368
- try {
369
- parsed = JSON.parse(firstLine);
370
- } catch {
371
- return;
372
- }
373
- refuseForeignFormatVersion(parsed);
374
- if (!isHeaderLine(parsed)) return void 0;
375
- return fromHeaderLine(parsed);
376
- }
377
- /**
378
- * Parse only the logical header fields needed by lightweight listing.
379
- * @param firstLine - first JSONL line without its trailing newline.
380
- * @returns the logical Session header, or `undefined` for a malformed line.
381
- */
382
- function parseHeaderMeta(firstLine) {
383
- return parseHeader(firstLine)?.meta;
384
- }
385
1132
  //#endregion
386
1133
  //#region lib/types/zstd-private-decoder.js
387
1134
  /**
@@ -614,172 +1361,820 @@ function scanZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) {
614
1361
  }
615
1362
  return { frames };
616
1363
  }
617
- /**
618
- * Compress one independently decodable, checksummed Zstandard frame.
619
- * @param input - JSONL bytes for a header or durable event batch.
620
- * @returns the complete encoded frame.
621
- */
622
- async function compressZstdFrame(input) {
623
- return zstdCompressAsync(input, CHECKSUM_OPTIONS);
1364
+ /**
1365
+ * Compress one independently decodable, checksummed Zstandard frame.
1366
+ * @param input - JSONL bytes for a header or durable event batch.
1367
+ * @returns the complete encoded frame.
1368
+ */
1369
+ async function compressZstdFrame(input) {
1370
+ return zstdCompressAsync(input, CHECKSUM_OPTIONS);
1371
+ }
1372
+ /**
1373
+ * Decompress one complete frame and validate its checksum.
1374
+ * @param input - one structurally complete Zstandard frame.
1375
+ * @returns the frame plaintext.
1376
+ */
1377
+ async function decompressZstdFrame(input) {
1378
+ return zstdDecompressAsync(input);
1379
+ }
1380
+ /**
1381
+ * Select the shared private decoder when the running Node 22/24/26 shape is
1382
+ * compatible, otherwise preserve correctness with the public one-shot API.
1383
+ * @returns a synchronous decoder with an implementation-independent lifecycle.
1384
+ */
1385
+ function createZstdFrameDecoder() {
1386
+ return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder();
1387
+ }
1388
+ /**
1389
+ * Recover available plaintext from a structurally incomplete final frame.
1390
+ * `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion;
1391
+ * callers must establish the torn frame boundary before using this helper.
1392
+ * @param input - available bytes from a known incomplete Zstandard frame.
1393
+ * @returns plaintext produced from the available input.
1394
+ */
1395
+ async function decompressZstdPrefix(input) {
1396
+ return zstdDecompressAsync(input, INCOMPLETE_FRAME_OPTIONS);
1397
+ }
1398
+ //#endregion
1399
+ //#region lib/types/migration-verifier.js
1400
+ /** Isolated verification for a staged or competing current JSONL generation. */
1401
+ /** Process-wide memory bound for full-generation verification isolates. */
1402
+ const MAX_CONCURRENT_VERIFIERS = 2;
1403
+ var VerificationScheduler = class {
1404
+ active = 0;
1405
+ waiting = [];
1406
+ async run(operation, signal) {
1407
+ const permit = this.acquire(signal);
1408
+ if (permit !== void 0) await permit;
1409
+ try {
1410
+ signal?.throwIfAborted();
1411
+ return await operation();
1412
+ } finally {
1413
+ this.release();
1414
+ }
1415
+ }
1416
+ acquire(signal) {
1417
+ signal?.throwIfAborted();
1418
+ if (this.active < MAX_CONCURRENT_VERIFIERS) {
1419
+ this.active += 1;
1420
+ return;
1421
+ }
1422
+ return new Promise((resolve, reject) => {
1423
+ const waiter = { grant: () => {
1424
+ signal?.removeEventListener("abort", abort);
1425
+ resolve();
1426
+ } };
1427
+ const abort = () => {
1428
+ const index = this.waiting.indexOf(waiter);
1429
+ this.waiting.splice(index, 1);
1430
+ reject(verifierAbortError(signal));
1431
+ };
1432
+ this.waiting.push(waiter);
1433
+ signal?.addEventListener("abort", abort, { once: true });
1434
+ });
1435
+ }
1436
+ release() {
1437
+ const next = this.waiting.shift();
1438
+ if (next === void 0) {
1439
+ this.active -= 1;
1440
+ return;
1441
+ }
1442
+ next.grant();
1443
+ }
1444
+ };
1445
+ const verificationScheduler = new VerificationScheduler();
1446
+ function workerSpawn(request) {
1447
+ /* v8 ignore next 3 -- built-worker coverage owns the bundled path. */
1448
+ if (!import.meta.url.endsWith(".ts")) return {
1449
+ entry: new URL("./worker.cjs", import.meta.url),
1450
+ options: {
1451
+ workerData: request,
1452
+ execArgv: []
1453
+ }
1454
+ };
1455
+ const workerEntry = new URL("./worker.ts", import.meta.url);
1456
+ const bootstrap = [
1457
+ `import { register as registerEsm } from ${JSON.stringify(import.meta.resolve("tsx/esm/api"))}`,
1458
+ `import { register as registerCjs } from ${JSON.stringify(import.meta.resolve("tsx/cjs/api"))}`,
1459
+ "registerCjs()",
1460
+ "registerEsm()",
1461
+ `await import(${JSON.stringify(workerEntry.href)})`
1462
+ ].join("\n");
1463
+ return {
1464
+ entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`),
1465
+ options: {
1466
+ workerData: request,
1467
+ execArgv: []
1468
+ }
1469
+ };
1470
+ }
1471
+ /**
1472
+ * Verify one current generation in a fresh Worker Thread.
1473
+ * @param path - staged or competing current-generation path.
1474
+ * @param compression - configured physical encoding.
1475
+ * @param expectedId - Session id expected in the decoded header.
1476
+ * @param expectedEventCount - exact logical event count expected after decoding.
1477
+ * @param expectedPrefix - verified physical prefix; an append tail may be present and is not validated.
1478
+ * @param signal - optional cancellation for scheduler wait and Worker execution.
1479
+ * @returns stable physical identity and digest observed by the worker.
1480
+ */
1481
+ function verifyCurrentGenerationInWorker(path, compression, expectedId, expectedEventCount, expectedPrefix, signal) {
1482
+ return verificationScheduler.run(() => runVerificationWorker(path, compression, expectedId, expectedEventCount, expectedPrefix, signal), signal);
1483
+ }
1484
+ function runVerificationWorker(path, compression, expectedId, expectedEventCount, expectedPrefix, signal) {
1485
+ signal?.throwIfAborted();
1486
+ const { entry, options } = workerSpawn({
1487
+ path,
1488
+ compression,
1489
+ expectedId,
1490
+ expectedEventCount,
1491
+ ...expectedPrefix === void 0 ? {} : { expectedPrefix }
1492
+ });
1493
+ const worker = new Worker(entry, options);
1494
+ return new Promise((resolve, reject) => {
1495
+ let settled = false;
1496
+ const cleanup = () => {
1497
+ signal?.removeEventListener("abort", abort);
1498
+ };
1499
+ const fail = (error) => {
1500
+ /* v8 ignore next -- a late error/exit races only after another terminal callback settled. */
1501
+ if (settled) return;
1502
+ settled = true;
1503
+ cleanup();
1504
+ worker.terminate().then(() => {
1505
+ reject(error);
1506
+ }, (cleanup) => {
1507
+ reject(new AggregateError([error, cleanup], "migration verifier termination failed"));
1508
+ });
1509
+ };
1510
+ worker.once("message", (value) => {
1511
+ /* v8 ignore next -- a duplicate message races only after another terminal callback settled. */
1512
+ if (settled) return;
1513
+ if (typeof value !== "object" || value === null || typeof value.ok !== "boolean") {
1514
+ fail(/* @__PURE__ */ new Error("migration verifier returned an invalid response"));
1515
+ return;
1516
+ }
1517
+ const response = value;
1518
+ if (!response.ok) {
1519
+ const error = new Error(response.message);
1520
+ if (response.stack !== void 0) error.stack = response.stack;
1521
+ fail(error);
1522
+ return;
1523
+ }
1524
+ settled = true;
1525
+ cleanup();
1526
+ worker.terminate().then(() => {
1527
+ resolve(response.result);
1528
+ }, (error) => {
1529
+ reject(error instanceof Error ? error : new Error(String(error)));
1530
+ });
1531
+ });
1532
+ worker.once("error", fail);
1533
+ worker.once("exit", (code) => {
1534
+ if (!settled) fail(/* @__PURE__ */ new Error(`migration verifier exited before reporting a result (code ${code})`));
1535
+ });
1536
+ const abort = () => {
1537
+ fail(verifierAbortError(signal));
1538
+ };
1539
+ signal?.addEventListener("abort", abort, { once: true });
1540
+ });
1541
+ }
1542
+ function verifierAbortError(signal) {
1543
+ const reason = signal?.reason;
1544
+ return reason instanceof Error ? reason : new Error("migration verifier aborted", { cause: reason });
624
1545
  }
1546
+ //#endregion
1547
+ //#region lib/types/generation.js
625
1548
  /**
626
- * Decompress one complete frame and validate its checksum.
627
- * @param input - one structurally complete Zstandard frame.
628
- * @returns the frame plaintext.
1549
+ * Durable whole-generation publication for JSONL Session artifacts.
1550
+ *
1551
+ * Format packages transform parsed JSON values. This module owns the physical
1552
+ * encoding, exact source identity, immutable generation files, and exclusive
1553
+ * current-generation publication for both configured JSONL suffixes.
1554
+ * @module @deepseek-ai/dsh-session-persistence-jsonl/generation
629
1555
  */
630
- async function decompressZstdFrame(input) {
631
- return zstdDecompressAsync(input);
1556
+ /** Internal scheduling bounds: preserve old decode cadence and cap each synchronous encode slice. */
1557
+ const MIGRATION_DECODE_YIELD_INTERVAL_MS = 500;
1558
+ const MIGRATION_WORK_CHUNK_BYTES = 1024 * 1024;
1559
+ const MIGRATION_WRITE_CHUNK_BYTES = 4 * 1024 * 1024;
1560
+ const ZSTD_CHECKSUM_OPTIONS = {
1561
+ chunkSize: MIGRATION_WORK_CHUNK_BYTES,
1562
+ params: { [constants.ZSTD_c_checksumFlag]: 1 }
1563
+ };
1564
+ /** A historical source changed after its single decode and migration pass. */
1565
+ var JsonlGenerationSourceChangedError = class extends Error {
1566
+ path;
1567
+ name = "JsonlGenerationSourceChangedError";
1568
+ /** @param path - historical generation whose revision changed. */
1569
+ constructor(path) {
1570
+ super(`historical session generation changed during migration: "${path}"`);
1571
+ this.path = path;
1572
+ }
1573
+ };
1574
+ /** A historical artifact is intact, but the format edge refuses its contents. */
1575
+ var JsonlGenerationUnsupportedMigrationError = class extends Error {
1576
+ fromVersion;
1577
+ reason;
1578
+ name = "JsonlGenerationUnsupportedMigrationError";
1579
+ /**
1580
+ * @param fromVersion - unchanged source generation version.
1581
+ * @param reason - format-edge refusal.
1582
+ */
1583
+ constructor(fromVersion, reason) {
1584
+ super(reason.message, { cause: reason });
1585
+ this.fromVersion = fromVersion;
1586
+ this.reason = reason;
1587
+ }
1588
+ };
1589
+ /** A current-generation filename already names different or invalid bytes. */
1590
+ var JsonlGenerationTargetConflictError = class extends Error {
1591
+ path;
1592
+ reason;
1593
+ name = "JsonlGenerationTargetConflictError";
1594
+ /**
1595
+ * @param path - immutable target that prevented exclusive publication.
1596
+ * @param reason - why the existing target cannot be accepted.
1597
+ */
1598
+ constructor(path, reason) {
1599
+ super(`current session generation already exists at "${path}": ${reason.message}`, { cause: reason });
1600
+ this.path = path;
1601
+ this.reason = reason;
1602
+ }
1603
+ };
1604
+ const defaultFileSystem = {
1605
+ open: (path, flags, mode) => open(path, flags, mode),
1606
+ readFile: (path, signal) => readFile(path, signal === void 0 ? void 0 : { signal }),
1607
+ readdir: (path) => readdir(path),
1608
+ stat: (path) => stat(path, { bigint: true }),
1609
+ lstat: (path) => lstat(path),
1610
+ link,
1611
+ rm: (path) => rm(path, { force: true })
1612
+ };
1613
+ const defaultInternals = {
1614
+ fs: defaultFileSystem,
1615
+ randomToken: () => randomBytes(8).toString("hex"),
1616
+ platform: process.platform,
1617
+ publishNewWin32: publishNewFileWin32,
1618
+ barrier: () => {}
1619
+ };
1620
+ function isEEXIST(error) {
1621
+ return error?.code === "EEXIST";
632
1622
  }
633
- /**
634
- * Select the shared private decoder when the running Node 22/24/26 shape is
635
- * compatible, otherwise preserve correctness with the public one-shot API.
636
- * @returns a synchronous decoder with an implementation-independent lifecycle.
637
- */
638
- function createZstdFrameDecoder() {
639
- return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder();
1623
+ /** Whether a filesystem-owned failure should retain its original errno and path. */
1624
+ function isErrnoException$1(error) {
1625
+ return typeof error?.code === "string";
640
1626
  }
641
- /**
642
- * Recover available plaintext from a structurally incomplete final frame.
643
- * `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion;
644
- * callers must establish the torn frame boundary before using this helper.
645
- * @param input - available bytes from a known incomplete Zstandard frame.
646
- * @returns plaintext produced from the available input.
647
- */
648
- async function decompressZstdPrefix(input) {
649
- return zstdDecompressAsync(input, INCOMPLETE_FRAME_OPTIONS);
1627
+ function identity(value) {
1628
+ return [
1629
+ value.dev,
1630
+ value.ino,
1631
+ value.size,
1632
+ value.mtimeNs,
1633
+ value.ctimeNs
1634
+ ].join(":");
650
1635
  }
651
- //#endregion
652
- //#region lib/types/win32.js
653
1636
  /**
654
- * Windows durable namespace helpers for the JSONL backend.
655
- *
656
- * POSIX publishes a newly-created log by creating a directory entry and then
657
- * fsyncing the parent directory. Windows does not expose that parent-directory
658
- * fsync contract through Node, so the Windows path uses the native durable
659
- * namespace primitive instead: create a staging object in the target directory
660
- * and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
661
- * replacement or cross-volume copy fallback.
662
- *
663
- * @module dsh-session-persistence-jsonl/win32
1637
+ * Read one stable revision of a JSONL file with a single retry. If an append
1638
+ * overlaps both reads, return the second read's committed pre-read prefix
1639
+ * instead of starving behind a continuous writer.
1640
+ * @param path - the generation file to read.
1641
+ * @param signal - optional cancellation for the stat/read work.
1642
+ * @returns the stable bytes (or the committed prefix) and their stat identity.
664
1643
  */
665
- const MOVEFILE_WRITE_THROUGH = 8;
666
- const ERROR_FILE_NOT_FOUND = 2;
667
- const ERROR_PATH_NOT_FOUND = 3;
668
- const ERROR_ACCESS_DENIED = 5;
669
- const ERROR_NOT_SAME_DEVICE = 17;
670
- const ERROR_FILE_EXISTS = 80;
671
- const ERROR_INVALID_NAME = 123;
672
- const ERROR_ALREADY_EXISTS = 183;
673
- let bindings;
674
- /** Load the small Win32 API lazily so non-Windows processes never load Koffi. */
675
- async function win32() {
676
- if (bindings !== void 0) return bindings;
677
- const kernel32 = (await import("koffi")).default.load("kernel32.dll");
678
- bindings = {
679
- moveFileExW: kernel32.func("__stdcall", "MoveFileExW", "int", [
680
- "str16",
681
- "str16",
682
- "uint"
683
- ]),
684
- getLastError: kernel32.func("__stdcall", "GetLastError", "uint", [])
1644
+ async function readStableJsonlFile(path, signal) {
1645
+ return defaultGenerationRuntime.readStable(path, signal);
1646
+ }
1647
+ async function readStableSnapshot(path, signal, fs) {
1648
+ signal?.throwIfAborted();
1649
+ let before = await fs.stat(path);
1650
+ for (let attempt = 0;; attempt += 1) {
1651
+ const bytes = await fs.readFile(path, signal);
1652
+ signal?.throwIfAborted();
1653
+ const after = await fs.stat(path);
1654
+ if (identity(before) === identity(after)) {
1655
+ signal?.throwIfAborted();
1656
+ return {
1657
+ bytes,
1658
+ identity: after
1659
+ };
1660
+ }
1661
+ if (attempt === 1) return {
1662
+ bytes: bytes.subarray(0, Number(before.size)),
1663
+ identity: before
1664
+ };
1665
+ before = after;
1666
+ }
1667
+ }
1668
+ /** Parse the version discriminator without validating any version-specific field. */
1669
+ function storedVersion(header) {
1670
+ if (typeof header !== "object" || header === null || Array.isArray(header)) throw new Error("corrupt session log: first line is not a JSON object");
1671
+ const version = header.version;
1672
+ if (!Number.isSafeInteger(version) || version < 0 || Object.is(version, -0)) throw new Error("corrupt session log: header version is not a non-negative safe integer");
1673
+ return version;
1674
+ }
1675
+ function parseJson(text, subject) {
1676
+ try {
1677
+ return JSON.parse(text);
1678
+ } catch (error) {
1679
+ throw new Error(`corrupt session log: ${subject} is not valid JSON`, { cause: error });
1680
+ }
1681
+ }
1682
+ /** Incremental JSONL parser that retains only one cross-frame record fragment. */
1683
+ var MigratingJsonlRows = class {
1684
+ restore;
1685
+ fragments = [];
1686
+ fragmentBytes = 0;
1687
+ rowIndex = 0;
1688
+ issue;
1689
+ constructor(restore) {
1690
+ this.restore = restore;
1691
+ }
1692
+ /** Consume plaintext bytes following the independently decoded header. */
1693
+ write(chunk) {
1694
+ let lineStart = 0;
1695
+ for (let newline = chunk.indexOf(10); newline !== -1; newline = chunk.indexOf(10, lineStart)) {
1696
+ const fragment = chunk.subarray(lineStart, newline);
1697
+ let line = fragment;
1698
+ if (this.fragments.length > 0) {
1699
+ if (fragment.length > 0) this.fragments.push(fragment);
1700
+ line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length);
1701
+ this.fragments = [];
1702
+ this.fragmentBytes = 0;
1703
+ }
1704
+ this.consume(line);
1705
+ lineStart = newline + 1;
1706
+ }
1707
+ if (lineStart < chunk.length) {
1708
+ const fragment = Buffer.from(chunk.subarray(lineStart));
1709
+ this.fragments.push(fragment);
1710
+ this.fragmentBytes += fragment.length;
1711
+ }
1712
+ }
1713
+ /** Refuse a record fragment left by structurally complete Zstandard frames. */
1714
+ assertCompleteFramesEndOnRecord() {
1715
+ if (this.fragments.length > 0) throw new Error("corrupt Zstandard session log: complete frame contains a torn JSONL record");
1716
+ }
1717
+ finish() {
1718
+ return this.restore.finish();
1719
+ }
1720
+ consume(line) {
1721
+ const index = this.rowIndex;
1722
+ this.rowIndex += 1;
1723
+ let row;
1724
+ try {
1725
+ row = parseJson(line.toString("utf8"), `row ${index + 1}`);
1726
+ } catch (error) {
1727
+ this.issue ??= asError(error);
1728
+ return;
1729
+ }
1730
+ if (this.issue !== void 0) {
1731
+ if (typeof row === "object" && row !== null && row.type === "turn/end") throw this.issue;
1732
+ return;
1733
+ }
1734
+ this.restore.decodeRow(row);
1735
+ }
1736
+ };
1737
+ async function startMigrationStream(headerRecord, sourceVersion, format, validateHistoricalHeader) {
1738
+ const value = parseJson(headerRecord.subarray(0, -1).toString("utf8"), "header line");
1739
+ const version = storedVersion(value);
1740
+ if (version !== sourceVersion) throw new Error(`resolved JSONL source filename identifies v${sourceVersion}, but its header identifies v${version}`);
1741
+ const header = value;
1742
+ const validation = validateHistoricalHeader?.(header);
1743
+ if (validation !== void 0) await validation;
1744
+ return { parser: new MigratingJsonlRows(format.createRestore(header)) };
1745
+ }
1746
+ async function consumeMigrationBytes(rows, chunks, signal) {
1747
+ signal?.throwIfAborted();
1748
+ let yieldDeadline = performance.now() + MIGRATION_DECODE_YIELD_INTERVAL_MS;
1749
+ for (const bytes of chunks) for (let offset = 0; offset < bytes.length; offset += MIGRATION_WORK_CHUNK_BYTES) {
1750
+ rows.write(bytes.subarray(offset, offset + MIGRATION_WORK_CHUNK_BYTES));
1751
+ if (performance.now() < yieldDeadline) continue;
1752
+ await scheduler.yield();
1753
+ signal?.throwIfAborted();
1754
+ yieldDeadline = performance.now() + MIGRATION_DECODE_YIELD_INTERVAL_MS;
1755
+ }
1756
+ }
1757
+ async function decodeStreamingMigration(bytes, compression, sourceVersion, format, validateHistoricalHeader, signal) {
1758
+ signal?.throwIfAborted();
1759
+ if (compression === "none") {
1760
+ const headerEnd = bytes.indexOf(10);
1761
+ if (headerEnd === -1) throw new Error("empty or header-less session log");
1762
+ const stream = await startMigrationStream(bytes.subarray(0, headerEnd + 1), sourceVersion, format, validateHistoricalHeader);
1763
+ signal?.throwIfAborted();
1764
+ const bodyEnd = bytes.lastIndexOf(10);
1765
+ if (bodyEnd > headerEnd) await consumeMigrationBytes(stream.parser, [bytes.subarray(headerEnd + 1, bodyEnd + 1)], signal);
1766
+ return stream.parser.finish();
1767
+ }
1768
+ const { frames, tornStart } = scanZstdFrames(bytes);
1769
+ if (frames.length === 0) throw new Error("empty or header-less Zstandard session log");
1770
+ const decoder = createZstdFrameDecoder();
1771
+ try {
1772
+ const decoded = decoder.decode(bytes, frames);
1773
+ const first = decoded.next();
1774
+ /* v8 ignore next -- a non-empty structural frame list yields once or throws. */
1775
+ if (first.done) throw new Error("empty or header-less Zstandard session log");
1776
+ assertIndependentHeaderFrame(first.value);
1777
+ const stream = await startMigrationStream(first.value, sourceVersion, format, validateHistoricalHeader);
1778
+ signal?.throwIfAborted();
1779
+ await consumeMigrationBytes(stream.parser, decoded, signal);
1780
+ stream.parser.assertCompleteFramesEndOnRecord();
1781
+ if (tornStart !== void 0) {
1782
+ let recovered = Buffer.alloc(0);
1783
+ try {
1784
+ recovered = await decompressZstdPrefix(bytes.subarray(tornStart));
1785
+ } catch {
1786
+ /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent. */
1787
+ if (signal?.aborted) signal.throwIfAborted();
1788
+ }
1789
+ signal?.throwIfAborted();
1790
+ const newline = recovered.lastIndexOf(10);
1791
+ if (newline !== -1) await consumeMigrationBytes(stream.parser, [recovered.subarray(0, newline + 1)], signal);
1792
+ }
1793
+ return stream.parser.finish();
1794
+ } finally {
1795
+ decoder.close();
1796
+ }
1797
+ }
1798
+ async function verifyCurrentGeneration(path, compression, expectedId, expectedEventCount, fs, expectedPrefix) {
1799
+ const before = await fs.stat(path);
1800
+ const bytes = await fs.readFile(path);
1801
+ const after = await fs.stat(path);
1802
+ if (expectedPrefix !== void 0) {
1803
+ if (bytes.length < expectedPrefix.bytes) throw new Error("target bytes are shorter than the migrated generation");
1804
+ const digest = createHash("sha256").update(bytes.subarray(0, expectedPrefix.bytes)).digest("hex");
1805
+ if (digest !== expectedPrefix.digest) throw new Error("target bytes do not begin with the migrated generation");
1806
+ return {
1807
+ identity: after,
1808
+ bytes: expectedPrefix.bytes,
1809
+ digest
1810
+ };
1811
+ }
1812
+ if (identity(before) !== identity(after)) throw new Error("current session generation changed during verification");
1813
+ const snapshot = {
1814
+ bytes,
1815
+ identity: after
1816
+ };
1817
+ const generation = decodeCurrentGeneration(snapshot.bytes, compression);
1818
+ validateStoredEvents(generation.meta, generation.events, {
1819
+ kind: "jsonl",
1820
+ path
1821
+ });
1822
+ if (generation.meta.id !== expectedId) throw new Error(`current session generation contains id "${generation.meta.id}", expected "${expectedId}"`);
1823
+ if (generation.events.length !== expectedEventCount) throw new Error(`current session generation contains ${generation.events.length} events, expected ${expectedEventCount}`);
1824
+ Session.fromRestore(generation.meta.id, generation.events, generation.meta, generation.inheritedEventCount, "detached");
1825
+ assertCurrentAssistantStreams(generation.events);
1826
+ return {
1827
+ identity: snapshot.identity,
1828
+ bytes: snapshot.bytes.length,
1829
+ digest: createHash("sha256").update(snapshot.bytes).digest("hex")
685
1830
  };
686
- return bindings;
687
1831
  }
688
- function errnoCode(win32Code) {
689
- switch (win32Code) {
690
- case ERROR_FILE_NOT_FOUND:
691
- case ERROR_PATH_NOT_FOUND: return "ENOENT";
692
- case ERROR_ACCESS_DENIED: return "EACCES";
693
- case ERROR_NOT_SAME_DEVICE: return "EXDEV";
694
- case ERROR_FILE_EXISTS:
695
- case ERROR_ALREADY_EXISTS: return "EEXIST";
696
- case ERROR_INVALID_NAME: return "EINVAL";
697
- default: return "EIO";
1832
+ /** Fully replay embedded streams only inside isolated current-generation verification. */
1833
+ function assertCurrentAssistantStreams(events) {
1834
+ for (const [index, event] of events.entries()) {
1835
+ if (event.type !== "assistant/message" && event.type !== "assistant/attempt") continue;
1836
+ const assembler = new BlockAssembler();
1837
+ let timed;
1838
+ try {
1839
+ timed = expandAssistantStream(event.data.stream);
1840
+ for (const member of timed) assembler.push(member.chunk);
1841
+ } catch (error) {
1842
+ throw new Error(`seed ${event.type} at index ${index} has an invalid embedded stream`, { cause: error });
1843
+ }
1844
+ if (event.type === "assistant/attempt" || timed.length === 0) continue;
1845
+ const content = event.data.interrupted === true ? assembler.interruptedBlocks() : assembler.blocks();
1846
+ if (!isDeepStrictEqual(event.data.message.content, content)) throw new Error(`seed assistant/message at index ${index} content disagrees with its embedded stream`);
1847
+ if (!isDeepStrictEqual(event.data.usage, assembler.usage)) throw new Error(`seed assistant/message at index ${index} usage disagrees with its embedded stream`);
1848
+ if (!isDeepStrictEqual(event.data.message.source.replayState, assembler.replayState)) throw new Error(`seed assistant/message at index ${index} replay state disagrees with its embedded stream`);
698
1849
  }
699
1850
  }
700
- function win32Error(syscall, win32Code, path, dest) {
701
- const code = errnoCode(win32Code);
702
- const error = /* @__PURE__ */ new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`);
703
- error.code = code;
704
- error.errno = win32Code;
705
- error.syscall = syscall;
706
- error.path = path;
707
- error.dest = dest;
708
- error.win32Code = win32Code;
709
- return error;
1851
+ function decodeCurrentGeneration(bytes, compression) {
1852
+ if (compression === "none") {
1853
+ const headerEnd = bytes.indexOf(10);
1854
+ if (headerEnd === -1) throw new Error("empty or header-less session log");
1855
+ const scanner = new SessionLogScanner(bytes.subarray(0, headerEnd + 1), "strict");
1856
+ scanner.write(bytes.subarray(headerEnd + 1));
1857
+ return finishCurrentGenerationScan(scanner);
1858
+ }
1859
+ const { frames, tornStart } = scanZstdFrames(bytes);
1860
+ if (frames.length === 0) throw new Error("empty or header-less Zstandard session log");
1861
+ if (tornStart !== void 0) throw new Error("current session generation has a torn physical tail");
1862
+ const decoder = createZstdFrameDecoder();
1863
+ try {
1864
+ const plaintext = decoder.decode(bytes, frames);
1865
+ const header = plaintext.next();
1866
+ /* v8 ignore next -- a non-empty structural frame list yields once or throws. */
1867
+ if (header.done) throw new Error("empty or header-less Zstandard session log");
1868
+ assertIndependentHeaderFrame(header.value);
1869
+ const scanner = new SessionLogScanner(header.value, "strict");
1870
+ for (const chunk of plaintext) scanner.write(chunk);
1871
+ return finishCurrentGenerationScan(scanner);
1872
+ } finally {
1873
+ decoder.close();
1874
+ }
710
1875
  }
711
- function isENOENT$1(error) {
712
- return error?.code === "ENOENT";
1876
+ function finishCurrentGenerationScan(scanner) {
1877
+ const inputBytes = scanner.checkpoint().inputBytes;
1878
+ const decoded = scanner.finish();
1879
+ if (decoded.committedBytes !== inputBytes) throw new Error("current session generation has a torn physical tail");
1880
+ return decoded;
713
1881
  }
714
- function isEEXIST(error) {
715
- return error?.code === "EEXIST";
1882
+ function stringifyJson(value, subject) {
1883
+ let text;
1884
+ try {
1885
+ text = JSON.stringify(value);
1886
+ } catch (error) {
1887
+ throw new Error(`${subject} is not lossless JSON`, { cause: error });
1888
+ }
1889
+ if (typeof text !== "string") throw new Error(`${subject} is not lossless JSON`);
1890
+ return text;
716
1891
  }
717
- async function assertDirectory(path) {
1892
+ function assertIndependentHeaderFrame(plaintext) {
1893
+ if (plaintext.length === 0 || plaintext.indexOf(10) !== plaintext.length - 1) throw new Error("corrupt Zstandard session log: first frame is not exactly one header line");
1894
+ }
1895
+ function assertGenerationPaths(sourcePath, sourceVersion, currentPath, currentVersion, compression) {
1896
+ const expectedSource = generationLogFilename(sourceVersion, compression);
1897
+ const expectedCurrent = generationLogFilename(currentVersion, compression);
1898
+ if (basename(sourcePath) !== expectedSource) throw new Error(`resolved JSONL source path must end with "${expectedSource}": ${sourcePath}`);
1899
+ if (basename(currentPath) !== expectedCurrent) throw new Error(`current JSONL generation path must end with "${expectedCurrent}": ${currentPath}`);
1900
+ if (dirname(sourcePath) !== dirname(currentPath)) throw new Error("source and current JSONL generations must share one Session directory");
1901
+ return logSuffix(compression);
1902
+ }
1903
+ async function syncDirectory(path, internals) {
1904
+ /* v8 ignore next -- Windows namespace operations request write-through directly. */
1905
+ if (internals.platform === "win32") return;
1906
+ const handle = await internals.fs.open(path, "r");
718
1907
  try {
719
- if ((await stat(path === parse(path).root ? path : toNamespacedPath(path))).isDirectory()) return true;
720
- const error = /* @__PURE__ */ new Error(`path exists but is not a directory: ${path}`);
721
- error.code = "ENOTDIR";
722
- error.path = path;
1908
+ await handle.sync();
1909
+ } finally {
1910
+ await handle.close();
1911
+ }
1912
+ }
1913
+ /** Produce bounded JSONL chunks while yielding between main-thread encoding slices. */
1914
+ async function* encodeMigrationRows(artifact, format, signal) {
1915
+ signal?.throwIfAborted();
1916
+ let lines = [];
1917
+ let bytes = 0;
1918
+ for (const value of artifact.events) {
1919
+ const line = `${stringifyJson(format.encodeEvent(value), `migrated Session event ${value.seq}`)}\n`;
1920
+ const lineBytes = Buffer.byteLength(line);
1921
+ if (bytes > 0 && bytes + lineBytes > MIGRATION_WORK_CHUNK_BYTES) {
1922
+ yield Buffer.from(lines.join(""));
1923
+ await scheduler.yield();
1924
+ signal?.throwIfAborted();
1925
+ lines = [];
1926
+ bytes = 0;
1927
+ }
1928
+ lines.push(line);
1929
+ bytes += lineBytes;
1930
+ }
1931
+ yield Buffer.from(lines.join(""));
1932
+ }
1933
+ async function writeMigrationChunks(chunks, write) {
1934
+ let pending = [];
1935
+ let bytes = 0;
1936
+ for await (const chunk of chunks) {
1937
+ pending.push(chunk);
1938
+ bytes += chunk.length;
1939
+ if (bytes < MIGRATION_WRITE_CHUNK_BYTES) continue;
1940
+ await write(pending.length === 1 ? pending[0] : Buffer.concat(pending, bytes));
1941
+ pending = [];
1942
+ bytes = 0;
1943
+ }
1944
+ if (bytes > 0) await write(pending.length === 1 ? pending[0] : Buffer.concat(pending, bytes));
1945
+ }
1946
+ /** Encode directly into one synced stage without a whole-artifact row or byte buffer. */
1947
+ async function writeSyncedTemp(currentPath, suffix, compression, artifact, format, signal, internals) {
1948
+ signal?.throwIfAborted();
1949
+ let path;
1950
+ let handle;
1951
+ for (;;) {
1952
+ path = join(dirname(currentPath), `session.migration.${internals.randomToken()}${suffix}.tmp`);
1953
+ try {
1954
+ handle = await internals.fs.open(path, "wx", 384);
1955
+ break;
1956
+ } catch (error) {
1957
+ if (isEEXIST(error)) continue;
1958
+ throw error;
1959
+ }
1960
+ }
1961
+ const hash = createHash("sha256");
1962
+ let bytes = 0;
1963
+ const write = async (chunk) => {
1964
+ await handle.writeFile(chunk);
1965
+ hash.update(chunk);
1966
+ bytes += chunk.length;
1967
+ };
1968
+ let failure;
1969
+ try {
1970
+ const headerValue = format.encodeHeader(artifact.header, artifact.inheritedEventCount);
1971
+ const header = Buffer.from(`${stringifyJson(headerValue, "migrated session header")}\n`);
1972
+ await write(compression === "zstd" ? await compressZstdFrame(header) : header);
1973
+ if (artifact.events.length > 0) {
1974
+ const rows = encodeMigrationRows(artifact, format, signal);
1975
+ if (compression === "none") await writeMigrationChunks(rows, write);
1976
+ else await new Promise((resolve, reject) => {
1977
+ pipeline(Readable.from(rows, {
1978
+ objectMode: false,
1979
+ highWaterMark: MIGRATION_WORK_CHUNK_BYTES
1980
+ }), createZstdCompress(ZSTD_CHECKSUM_OPTIONS), async (source) => {
1981
+ await writeMigrationChunks(source, write);
1982
+ }, (error) => {
1983
+ if (error instanceof Error) reject(error);
1984
+ else resolve();
1985
+ });
1986
+ });
1987
+ }
1988
+ signal?.throwIfAborted();
1989
+ await handle.sync();
1990
+ } catch (error) {
1991
+ failure = error;
1992
+ }
1993
+ try {
1994
+ await handle.close();
1995
+ } catch (error) {
1996
+ failure = failure === void 0 ? error : new AggregateError([failure, error], `failed to write and close migration stage "${path}"`);
1997
+ }
1998
+ if (failure !== void 0) {
1999
+ const writeError = failure instanceof Error ? failure : new Error("migration stage write failed with a non-Error rejection", { cause: failure });
2000
+ await removeTemporary(path, writeError, internals);
2001
+ throw writeError;
2002
+ }
2003
+ return {
2004
+ path,
2005
+ bytes,
2006
+ digest: hash.digest("hex")
2007
+ };
2008
+ }
2009
+ /** Remove one temporary file without hiding the operation failure that made it disposable. */
2010
+ async function removeTemporary(path, primaryFailure, internals) {
2011
+ try {
2012
+ await internals.fs.rm(path);
2013
+ } catch (cleanupFailure) {
2014
+ throw new AggregateError([primaryFailure, cleanupFailure], `failed to clean migration temporary "${path}" after an earlier failure`);
2015
+ }
2016
+ }
2017
+ /** Remove a redundant stage after the target has been validated as committed. */
2018
+ async function removeCommittedTemporary(path, internals) {
2019
+ try {
2020
+ await internals.fs.rm(path);
2021
+ } catch {}
2022
+ }
2023
+ async function publishCurrentExclusive(staged, currentPath, internals) {
2024
+ if (internals.platform === "win32") try {
2025
+ await internals.publishNewWin32(staged, currentPath);
2026
+ return true;
2027
+ } catch (error) {
2028
+ /* v8 ignore else -- native helper tests own non-collision Win32 failures. */
2029
+ if (isEEXIST(error)) return false;
2030
+ /* v8 ignore next -- the filesystem error is already complete. */
723
2031
  throw error;
2032
+ }
2033
+ try {
2034
+ await internals.fs.link(staged, currentPath);
724
2035
  } catch (error) {
725
- if (isENOENT$1(error)) return false;
2036
+ /* v8 ignore else -- a non-collision filesystem error propagates unchanged. */
2037
+ if (isEEXIST(error)) return false;
2038
+ /* v8 ignore next -- the filesystem error is already complete. */
726
2039
  throw error;
727
2040
  }
2041
+ await syncDirectory(dirname(currentPath), internals);
2042
+ return true;
728
2043
  }
729
- /**
730
- * Publish `existing` at `replacement` with Windows write-through rename
731
- * semantics. The destination must not already exist; the move must stay within
732
- * the volume (no copy fallback flag is set).
733
- * @param existing - the synced staging path to move.
734
- * @param replacement - the final path, which must not already exist.
735
- */
736
- async function publishNewFileWin32(existing, replacement) {
737
- const api = await win32();
738
- if (api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH) === 0) throw win32Error("MoveFileExW", api.getLastError(), existing, replacement);
2044
+ function asError(error) {
2045
+ return error instanceof Error ? error : new Error("current-generation validation failed with a non-Error rejection", { cause: error });
739
2046
  }
740
- /**
741
- * Create `target` and its missing ancestors with durable Windows namespace
742
- * publication. Each missing directory is first created as a random staging
743
- * sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
744
- * with another creator are accepted only after verifying the winner is a
745
- * directory.
746
- * @param target - the absolute directory path to create durably when absent.
747
- */
748
- async function ensureDurableDirectoryWin32(target) {
749
- const absolute = resolve(target);
750
- const root = parse(absolute).root;
751
- await assertDirectory(root);
752
- const segments = absolute.slice(root.length).split(/[\\/]+/).filter((part) => part.length > 0);
753
- let current = root;
754
- for (const segment of segments) {
755
- const next = join(current, segment);
756
- if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next);
757
- current = next;
2047
+ async function inspectExpectedCurrent(currentPath, internals, inspect) {
2048
+ try {
2049
+ const expectedName = basename(currentPath);
2050
+ const names = await internals.fs.readdir(dirname(currentPath));
2051
+ if (!names.includes(expectedName)) {
2052
+ const noncanonical = names.find((name) => name.toLowerCase() === expectedName.toLowerCase());
2053
+ if (noncanonical !== void 0) throw new Error(`target resolves to noncanonical directory entry "${noncanonical}"`);
2054
+ }
2055
+ const info = await internals.fs.lstat(currentPath);
2056
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`target is a ${info.isSymbolicLink() ? "symbolic link" : "non-regular file"}`);
2057
+ return await inspect();
2058
+ } catch (error) {
2059
+ if (isErrnoException$1(error)) throw error;
2060
+ throw new JsonlGenerationTargetConflictError(currentPath, asError(error));
758
2061
  }
759
2062
  }
760
- async function createLeafDirectoryWin32(parent, target) {
761
- const staging = await mkdtemp(toNamespacedPath(join(parent, ".dsh-mkdir-")));
2063
+ function withOverrides(overrides) {
2064
+ return {
2065
+ ...defaultInternals,
2066
+ ...overrides,
2067
+ fs: {
2068
+ ...defaultFileSystem,
2069
+ ...overrides.fs
2070
+ }
2071
+ };
2072
+ }
2073
+ async function publishPreparedMigration(options, suffix, artifact, sourceIdentity, internals) {
2074
+ await scheduler.yield();
2075
+ const { sourcePath, currentPath, compression, verifyCurrentFile } = options;
2076
+ const eventCount = artifact.events.length;
2077
+ let staged = await writeSyncedTemp(currentPath, suffix, compression, artifact, options.format, void 0, internals);
762
2078
  try {
763
- await publishNewFileWin32(staging, target);
2079
+ const verifiedStage = await verifyCurrentFile(staged.path, compression, artifact.header.id, eventCount);
2080
+ if (verifiedStage.bytes !== staged.bytes || verifiedStage.digest !== staged.digest) throw new Error("staged session generation changed during verification");
2081
+ await internals.barrier("before-source-check", 1);
2082
+ if (identity(await internals.fs.stat(sourcePath)) !== identity(sourceIdentity)) throw new JsonlGenerationSourceChangedError(sourcePath);
2083
+ const published = await publishCurrentExclusive(staged.path, currentPath, internals);
2084
+ if (published && internals.platform === "win32") staged = {
2085
+ ...staged,
2086
+ path: ""
2087
+ };
2088
+ await internals.barrier("after-publication", 1);
2089
+ let currentIdentity;
2090
+ if (published) {
2091
+ if (staged.path !== "") {
2092
+ await removeCommittedTemporary(staged.path, internals);
2093
+ staged = {
2094
+ ...staged,
2095
+ path: ""
2096
+ };
2097
+ }
2098
+ currentIdentity = await internals.fs.stat(currentPath);
2099
+ } else {
2100
+ currentIdentity = (await inspectExpectedCurrent(currentPath, internals, async () => {
2101
+ const candidate = await verifyCurrentFile(currentPath, compression, artifact.header.id, eventCount, staged);
2102
+ if (candidate.bytes !== staged.bytes || candidate.digest !== staged.digest) throw new Error("target bytes differ from the migrated generation");
2103
+ return candidate;
2104
+ })).identity;
2105
+ await removeCommittedTemporary(staged.path, internals);
2106
+ staged = {
2107
+ ...staged,
2108
+ path: ""
2109
+ };
2110
+ }
2111
+ return currentIdentity;
764
2112
  } catch (error) {
765
- await rm(staging, {
766
- recursive: true,
767
- force: true
768
- });
769
- if (isEEXIST(error) && await assertDirectory(target)) return;
2113
+ if (staged.path !== "") await removeTemporary(staged.path, error, internals);
2114
+ throw error;
2115
+ }
2116
+ }
2117
+ async function prepareMigration(options, internals) {
2118
+ const { sourcePath, sourceVersion, currentPath, compression, format, signal } = options;
2119
+ const suffix = assertGenerationPaths(sourcePath, sourceVersion, currentPath, format.currentVersion, compression);
2120
+ if (sourceVersion >= format.currentVersion) throw new Error(`migration preparation requires a historical source, got v${sourceVersion}`);
2121
+ const source = await readStableSnapshot(sourcePath, signal, internals.fs);
2122
+ let artifact;
2123
+ try {
2124
+ artifact = await decodeStreamingMigration(source.bytes, compression, sourceVersion, format, options.validateHistoricalHeader, signal);
2125
+ } catch (error) {
2126
+ if (format.isUnsupportedMigrationError?.(error) === true) throw new JsonlGenerationUnsupportedMigrationError(sourceVersion, error);
770
2127
  throw error;
771
2128
  }
2129
+ if (artifact.header.version !== format.currentVersion) throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`);
2130
+ const sourceIdentity = source.identity;
2131
+ let publication;
2132
+ return {
2133
+ sourceIdentity,
2134
+ artifact,
2135
+ publish() {
2136
+ if (publication === void 0) publication = publishPreparedMigration(options, suffix, artifact, sourceIdentity, internals);
2137
+ return publication;
2138
+ }
2139
+ };
2140
+ }
2141
+ /**
2142
+ * Decode and migrate one historical generation without writing its successor.
2143
+ * @param options - resolved source, current target, format adapter, and load cancellation.
2144
+ * @returns the current artifact and an idempotent explicit publication operation.
2145
+ */
2146
+ function prepareJsonlMigration(options) {
2147
+ return defaultGenerationRuntime.prepare(options);
2148
+ }
2149
+ /**
2150
+ * Create one generation runtime with fixed filesystem and publication dependencies.
2151
+ * @param overrides - deterministic filesystem, platform, and race dependencies.
2152
+ * @returns bound generation operations.
2153
+ */
2154
+ function createJsonlGenerationRuntime(overrides = {}) {
2155
+ const internals = withOverrides(overrides);
2156
+ return {
2157
+ readStable: (path, signal) => readStableSnapshot(path, signal, internals.fs),
2158
+ prepare: (options) => prepareMigration(options, internals),
2159
+ verify: (path, compression, expectedId, expectedEventCount, expectedPrefix) => verifyCurrentGeneration(path, compression, expectedId, expectedEventCount, internals.fs, expectedPrefix)
2160
+ };
772
2161
  }
2162
+ const defaultGenerationRuntime = createJsonlGenerationRuntime();
773
2163
  //#endregion
774
2164
  //#region lib/types/index.js
775
2165
  /**
776
2166
  * JSONL durable session-persistence backend. It stores a header and contiguous
777
- * events in one append-only file per session, and delegates orchestration to
778
- * {@link PersistenceCoordinator}. Its side-effect-free locator returns the
779
- * absolute per-session log target before materialization.
2167
+ * events in immutable generation files under one directory per session and serves the handle-based
2168
+ * `SessionPersistence` API: `create`/`open` return per-session handles, and
2169
+ * every read validates the same fail-closed storage contract.
780
2170
  * @module @deepseek-ai/dsh-session-persistence-jsonl
781
2171
  */
782
- const DEFAULT_PACK_CHUNKS = true;
2172
+ /**
2173
+ * Internal handoff-reuse policy, not deployment configuration: a cold
2174
+ * observation and the resume that immediately follows it reuse one parsed
2175
+ * log, so the memo only needs the sessions in flight between those steps.
2176
+ */
2177
+ const COLD_LOG_MEMO_MAX_ENTRIES = 2;
783
2178
  const DEFAULT_COMPRESSION = "zstd";
784
2179
  /**
785
2180
  * Internal scheduling constant, not deployment configuration: balance
@@ -793,7 +2188,28 @@ function assertZstdHeaderFrame(plaintext) {
793
2188
  }
794
2189
  /** Loader schema for the JSONL artifact's physical encoding. */
795
2190
  const JsonlCompressionSchema = z.union([z.const("zstd"), z.const("none")]).default(DEFAULT_COMPRESSION);
796
- /** Build the source-qualified revision shared by full and lightweight reads. */
2191
+ /** Deep-freeze one acyclic stored JSON event without recursive calls. */
2192
+ function freezeStoredEvent(event) {
2193
+ const pending = [event];
2194
+ while (pending.length > 0) {
2195
+ const current = pending.pop();
2196
+ Object.freeze(current);
2197
+ for (const key in current) {
2198
+ const child = current[key];
2199
+ if (child !== null && typeof child === "object") pending.push(child);
2200
+ }
2201
+ }
2202
+ }
2203
+ /** Establish immutable sharing for one decoded event graph and report that state. */
2204
+ function freezeStoredEvents(events) {
2205
+ for (const event of events) freezeStoredEvent(event);
2206
+ Object.freeze(events);
2207
+ return {
2208
+ eventState: "shared-frozen",
2209
+ events
2210
+ };
2211
+ }
2212
+ /** Build the stat-derived best-effort change token shared by full and lightweight reads. */
797
2213
  function fileRevision(identity) {
798
2214
  return SessionPersistenceRevision([
799
2215
  identity.dev,
@@ -807,206 +2223,572 @@ function fileRevision(identity) {
807
2223
  function isENOENT(error) {
808
2224
  return error?.code === "ENOENT";
809
2225
  }
2226
+ /** Whether a filesystem-owned failure should retain its original errno and path. */
2227
+ function isErrnoException(error) {
2228
+ return typeof error?.code === "string";
2229
+ }
2230
+ /** Preserve an Error abort reason and normalize hostile non-Error reasons. */
2231
+ function abortError(signal) {
2232
+ return signal.reason instanceof Error ? signal.reason : new Error("session migration preparation aborted", { cause: signal.reason });
2233
+ }
2234
+ /** Let one caller stop waiting without transferring cancellation ownership to shared work. */
2235
+ function waitWithAbort(operation, signal) {
2236
+ if (signal === void 0) return operation;
2237
+ /* v8 ignore next -- requireStoredLog synchronously rechecks the signal immediately before waiting. */
2238
+ if (signal.aborted) return Promise.reject(abortError(signal));
2239
+ return new Promise((resolve, reject) => {
2240
+ const stopWaiting = () => {
2241
+ reject(abortError(signal));
2242
+ };
2243
+ signal.addEventListener("abort", stopWaiting, { once: true });
2244
+ operation.then((value) => {
2245
+ signal.removeEventListener("abort", stopWaiting);
2246
+ resolve(value);
2247
+ }, (error) => {
2248
+ signal.removeEventListener("abort", stopWaiting);
2249
+ /* v8 ignore else -- the preparation owner normalizes every rejection before this waiter sees it. */
2250
+ if (error instanceof Error) reject(error);
2251
+ else reject(new Error("session migration preparation failed", { cause: error }));
2252
+ });
2253
+ });
2254
+ }
810
2255
  /**
811
2256
  * The JSONL persistence backend. Load as a plugin; it registers as
812
- * `ctx.sessionPersistence` and (via the coordinator) installs the write-path
813
- * listeners. Its torn-tail marker carries the byte offset and any events
814
- * recovered from an incomplete final Zstandard frame.
2257
+ * `ctx.sessionPersistence`. Sessions materialize lazily: a created session is
2258
+ * visible to this process immediately, reaches disk on its first append or
2259
+ * flush, and never existed if the process crashes before that.
815
2260
  */
816
2261
  var JsonlSessionPersistence = class extends SessionPersistence {
817
2262
  config;
818
- supportsRawArtifacts = true;
819
- static inject = ["sessions"];
820
2263
  static Config = z.object({
821
2264
  root: z.string().required(),
822
- packChunks: z.boolean().default(DEFAULT_PACK_CHUNKS),
823
- compression: JsonlCompressionSchema,
824
- preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE),
825
- writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS).default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS)
2265
+ compression: JsonlCompressionSchema
826
2266
  });
827
- /**
828
- * Backend label for coordinator diagnostics and effects. It shadows
829
- * `Service.name` without changing the service key captured by the base
830
- * constructor.
831
- */
2267
+ /** Backend label for diagnostics and effects; shadows `Service.name` without changing the service key. */
832
2268
  name = "session-persistence-jsonl";
833
2269
  root;
834
- packChunks;
835
2270
  compression;
836
- coordinator;
837
2271
  rootEncodingCheck;
2272
+ tracker = new JsonlBackendTracker(this.name);
2273
+ generationFormat;
2274
+ /**
2275
+ * Bounded LRU of parsed, validated stored logs keyed by session id and
2276
+ * guarded by the stat-derived revision, so an immediate cold-read handoff
2277
+ * (observation then resume) parses the artifact once. Every local mutation
2278
+ * for an id invalidates its entry; a foreign write misses through the
2279
+ * revision guard.
2280
+ */
2281
+ coldLogMemo = /* @__PURE__ */ new Map();
2282
+ /** One joinable decode/migration operation per selected historical Session file revision. */
2283
+ migrationPreparations = /* @__PURE__ */ new Map();
838
2284
  constructor(ctx, config) {
839
2285
  super(ctx);
840
2286
  this.config = config;
2287
+ /* v8 ignore next 5 -- generated catalog and Session source share one build-time version owner. */
2288
+ if (sessionFormatCatalog.currentVersion !== SESSION_FORMAT_VERSION) throw new Error(`session-persistence-jsonl: format catalog v${sessionFormatCatalog.currentVersion} does not match Session v${SESSION_FORMAT_VERSION}`);
841
2289
  this.root = resolve(config.root);
842
- const preparedSessionCacheSize = config.preparedSessionCacheSize ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE;
843
- const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs ?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS;
844
- this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS;
845
2290
  this.compression = config.compression ?? DEFAULT_COMPRESSION;
2291
+ this.generationFormat = {
2292
+ currentVersion: sessionFormatCatalog.currentVersion,
2293
+ createRestore: (header) => sessionFormatCatalog.createRestore(header, {
2294
+ recovery: "recoverable",
2295
+ validation: "transformed"
2296
+ }),
2297
+ encodeHeader: (header, inheritedEventCount) => sessionFormatCatalog.encodeCurrentHeader(header, inheritedEventCount),
2298
+ encodeEvent: (event) => sessionFormatCatalog.encodeCurrentEvent(event),
2299
+ isUnsupportedMigrationError: (error) => error instanceof SessionFormatUnsupportedMigrationError
2300
+ };
846
2301
  this.assertUsableRoot();
847
- this.coordinator = new PersistenceCoordinator(this.ctx, this, {
848
- preparedSessionCacheSize,
849
- writeBatchMaxDelayMs
850
- });
2302
+ this.tracker.install(ctx);
851
2303
  }
852
- /** Resolve the absolute target path without touching the filesystem. */
2304
+ /**
2305
+ * Refusal-diagnostics hook: the absolute target path, without touching the filesystem.
2306
+ * @param meta - the stored header naming the session and its cwd.
2307
+ * @returns the artifact kind and absolute path.
2308
+ */
853
2309
  locate(meta) {
854
2310
  return {
855
2311
  kind: "jsonl",
856
2312
  path: logPath(this.root, meta.cwd, meta.id, this.compression)
857
2313
  };
858
2314
  }
859
- create(meta, inheritedEventCount) {
860
- return this.coordinator.create(meta, inheritedEventCount);
861
- }
862
- ensureMaterialized(session) {
863
- return this.coordinator.ensureMaterialized(session);
864
- }
865
- append(id, events) {
866
- return this.coordinator.append(id, events);
867
- }
868
- prepare(id, signal) {
869
- return this.coordinator.prepare(id, signal);
870
- }
871
- load(id) {
872
- return this.coordinator.load(id);
873
- }
874
- inspect(id, signal) {
875
- return this.coordinator.inspect(id, signal);
876
- }
877
- borrowSession(id, signal) {
878
- return this.coordinator.borrowSession(id, signal);
879
- }
880
- readFrom(id, fromSeq, signal) {
881
- return this.coordinator.readFrom(id, fromSeq, signal);
2315
+ /**
2316
+ * Create a new stored session and take its write ownership. The session is
2317
+ * visible to this process immediately; the physical artifact appears on the
2318
+ * first append or flush.
2319
+ * @param header - the immutable header to store; must be losslessly
2320
+ * JSON-serializable with a non-negative safe-integer `createdAt`.
2321
+ * @param options - optional cancellation.
2322
+ * @returns the owned write handle.
2323
+ */
2324
+ async create(header, options) {
2325
+ options?.signal?.throwIfAborted();
2326
+ const snapshot = materializeCreateHeader(header);
2327
+ toHeaderLine(snapshot, options?.inheritedEventCount);
2328
+ const inheritedEventCount = SessionLogOffset(options?.inheritedEventCount ?? 0);
2329
+ await this.ensureRootEncoding();
2330
+ options?.signal?.throwIfAborted();
2331
+ if (this.tracker.hasPending(snapshot.id) || await this.findLog(snapshot.id, options?.signal) !== void 0) throw new SessionAlreadyExistsError(snapshot.id);
2332
+ options?.signal?.throwIfAborted();
2333
+ this.tracker.registerCreated(snapshot, inheritedEventCount);
2334
+ return this.tracker.adopt(new JsonlSessionHandle(this, snapshot.id, snapshot, "write", {
2335
+ cursor: 0,
2336
+ materialized: false,
2337
+ inheritedEventCount
2338
+ }));
882
2339
  }
883
- /** Read a stored prefix by id across all project directories when cwd is unknown. */
884
- async loadStored(id, signal) {
885
- signal?.throwIfAborted();
2340
+ /**
2341
+ * Open an existing stored session for `read` or single-writer `write`.
2342
+ * @param id - the stored session to open.
2343
+ * @param access - `read` (no ownership) or `write` (atomic in-process claim).
2344
+ * @param options - optional cancellation.
2345
+ * @returns the open handle.
2346
+ */
2347
+ async open(id, access, options) {
2348
+ options?.signal?.throwIfAborted();
886
2349
  await this.ensureRootEncoding();
887
- signal?.throwIfAborted();
888
- const path = await this.findLog(id, signal);
889
- if (path === void 0) return void 0;
890
- return this.readPrefix(path, id, signal);
2350
+ options?.signal?.throwIfAborted();
2351
+ const pending = this.tracker.pendingOf(id);
2352
+ if (access === "read") {
2353
+ if (pending !== void 0) return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, "read", {
2354
+ cursor: 0,
2355
+ materialized: false,
2356
+ inheritedEventCount: pending.inheritedEventCount
2357
+ }));
2358
+ const stored = await this.requireStoredLog(id, options?.signal);
2359
+ let state;
2360
+ if (stored.status === "prepared") state = {
2361
+ cursor: 0,
2362
+ materialized: true,
2363
+ inheritedEventCount: stored.inheritedEventCount,
2364
+ primed: stored
2365
+ };
2366
+ else state = {
2367
+ cursor: 0,
2368
+ materialized: true,
2369
+ inheritedEventCount: stored.inheritedEventCount
2370
+ };
2371
+ return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, "read", state));
2372
+ }
2373
+ this.tracker.claimWrite(id);
2374
+ let lease;
2375
+ try {
2376
+ const resolved = await this.findLog(id, options?.signal);
2377
+ if (resolved === void 0) throw new SessionPersistenceNotFoundError(id);
2378
+ lease = await this.acquireLease(id, void 0, dirname(resolved.currentPath));
2379
+ const prepared = await this.requireStoredLog(id, options?.signal);
2380
+ options?.signal?.throwIfAborted();
2381
+ let stored;
2382
+ if (prepared.status === "prepared") stored = await this.publishStoredMigration(id, prepared);
2383
+ else stored = prepared;
2384
+ options?.signal?.throwIfAborted();
2385
+ return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, "write", {
2386
+ cursor: stored.events.length,
2387
+ materialized: true,
2388
+ tornTruncateTo: stored.tornTruncateTo,
2389
+ recoveredTail: stored.recoveredTail,
2390
+ inheritedEventCount: stored.inheritedEventCount,
2391
+ primed: stored
2392
+ }, lease));
2393
+ } catch (error) {
2394
+ /* v8 ignore next -- typed backends and fs reject with Error */
2395
+ const failure = error instanceof Error ? error : new Error(String(error));
2396
+ let releaseFailure;
2397
+ try {
2398
+ await lease?.release();
2399
+ } catch (raw) {
2400
+ /* v8 ignore next -- lock releases reject with Error */
2401
+ releaseFailure = raw instanceof Error ? raw : new Error(String(raw));
2402
+ }
2403
+ this.tracker.releaseClaim(id);
2404
+ if (releaseFailure !== void 0) throw new AggregateError([failure, releaseFailure], `session "${id}": write open failed and its lock release failed`);
2405
+ throw failure;
2406
+ }
891
2407
  }
892
2408
  /**
893
- * Read one log's stat-derived revision without loading its event bytes.
894
- * Resolving an id with unknown cwd still scans the project directories.
2409
+ * Flush every active write handle in one durability barrier; see the seam
2410
+ * contract.
2411
+ * @returns resolution once every write handle active at the call has flushed.
895
2412
  */
896
- async readStoredRevision(id, signal) {
897
- signal?.throwIfAborted();
2413
+ flush() {
2414
+ return this.tracker.flushAll();
2415
+ }
2416
+ /**
2417
+ * Observe one stored session without reading its event log.
2418
+ * @param id - the stored session to observe.
2419
+ * @param options - optional cancellation.
2420
+ * @returns the snapshot (`sizeBytes` carries the physical artifact size), or
2421
+ * `undefined` when the session does not exist.
2422
+ */
2423
+ async stat(id, options) {
2424
+ options?.signal?.throwIfAborted();
898
2425
  await this.ensureRootEncoding();
899
- signal?.throwIfAborted();
900
- const path = await this.findLog(id, signal);
901
- if (path === void 0) return void 0;
2426
+ options?.signal?.throwIfAborted();
2427
+ const pending = this.tracker.pendingOf(id);
2428
+ if (pending !== void 0) return {
2429
+ header: pending.header,
2430
+ revision: pending.revision
2431
+ };
2432
+ const selected = await this.findLog(id, options?.signal);
2433
+ if (selected === void 0) return void 0;
2434
+ const header = await this.readGenerationHeader(selected, id, options?.signal);
2435
+ if (header === void 0) return void 0;
902
2436
  try {
903
- const identity = await stat(path, { bigint: true });
904
- signal?.throwIfAborted();
905
- return fileRevision(identity);
2437
+ const identity = await stat(selected.sourcePath, { bigint: true });
2438
+ options?.signal?.throwIfAborted();
2439
+ return {
2440
+ header,
2441
+ revision: fileRevision(identity),
2442
+ sizeBytes: Number(identity.size)
2443
+ };
906
2444
  } catch (error) {
907
- signal?.throwIfAborted();
2445
+ options?.signal?.throwIfAborted();
908
2446
  if (isENOENT(error)) return void 0;
909
2447
  throw error;
910
2448
  }
911
2449
  }
912
2450
  /**
913
- * Read a session's stored artifact text verbatim: the durable file bytes
914
- * decoded from this backend's physical encoding (complete zstd frames
915
- * concatenated, or UTF-8 plaintext). The content is the exact JSONL text the
916
- * backend wrote — never a reconstruction from parsed events — so packed-
917
- * chunk rows, key order, and line breaks survive byte-for-byte. A torn
918
- * final frame is omitted, matching the committed-prefix semantics of every
919
- * other read.
920
- * @param id - the persisted session to read.
921
- * @param signal - optional cancellation for the stat/read/decode work.
922
- * @returns the raw artifact text plus the header parsed from its own first
923
- * line, or `undefined` when the session has no stored artifact.
2451
+ * List every stored session visible to this process: materialized artifacts
2452
+ * plus this process's created-but-unmaterialized sessions.
2453
+ * @param options - optional cancellation.
2454
+ * @returns one snapshot per session, in no promised order.
924
2455
  */
925
- async readRaw(id, signal) {
926
- signal?.throwIfAborted();
927
- await this.ensureRootEncoding();
928
- signal?.throwIfAborted();
929
- const path = await this.findLog(id, signal);
930
- if (path === void 0) return void 0;
931
- const { buffer } = await this.readStableFile(path, signal);
932
- let content;
933
- if (this.compression === "zstd") {
934
- const { frames } = scanZstdFrames(buffer);
935
- if (frames.length === 0) throw new Error("empty or header-less Zstandard session log");
936
- const decoder = createZstdFrameDecoder();
937
- const plaintexts = [];
938
- for (const plaintext of decoder.decode(buffer, frames)) {
2456
+ async list(options) {
2457
+ const signal = options?.signal;
2458
+ const snapshots = [];
2459
+ const listed = /* @__PURE__ */ new Set();
2460
+ const pending = [...this.tracker.pendingEntries()];
2461
+ for (const artifact of await this.listArtifacts(signal)) {
2462
+ signal?.throwIfAborted();
2463
+ try {
2464
+ const identity = await stat(artifact.path, { bigint: true });
939
2465
  signal?.throwIfAborted();
940
- plaintexts.push(Buffer.from(plaintext));
2466
+ listed.add(artifact.header.id);
2467
+ snapshots.push({
2468
+ header: artifact.header,
2469
+ revision: fileRevision(identity),
2470
+ sizeBytes: Number(identity.size)
2471
+ });
2472
+ } catch (error) {
2473
+ signal?.throwIfAborted();
2474
+ if (!isENOENT(error)) throw error;
941
2475
  }
942
- content = Buffer.concat(plaintexts).toString("utf8");
943
- } else content = buffer.toString("utf8");
944
- const storage = parseHeader(content.split("\n", 1)[0]);
945
- if (storage === void 0 || storage.meta.id !== id) throw new Error(`corrupt session log: invalid header line in "${path}"`);
946
- return {
947
- ...storage,
948
- filename: "session.jsonl",
949
- content
950
- };
2476
+ }
2477
+ for (const [id, entry] of pending) if (!listed.has(id)) snapshots.push({
2478
+ header: entry.header,
2479
+ revision: entry.revision
2480
+ });
2481
+ signal?.throwIfAborted();
2482
+ return snapshots;
951
2483
  }
952
- /**
953
- * Read a file's bytes under a revision-stable loop: a writer appending
954
- * between stat and readFile would yield a torn physical file, so retry
955
- * while the stat revision changes.
956
- * @param path - the artifact file to read.
957
- * @param signal - optional cancellation for the stat/read work.
958
- * @returns the stable bytes and the revision that matched both stats.
959
- */
960
- async readStableFile(path, signal) {
961
- for (;;) {
2484
+ /** Resolve and read one stored log, refusing loudly when the artifact is absent. */
2485
+ async requireStoredLog(id, signal) {
2486
+ const selected = await this.findLog(id, signal);
2487
+ if (selected === void 0) throw new SessionPersistenceNotFoundError(id);
2488
+ if (selected.sourceVersion < SESSION_FORMAT_VERSION) {
2489
+ const sourceRevision = fileRevision(await stat(selected.sourcePath, { bigint: true }));
962
2490
  signal?.throwIfAborted();
963
- const before = fileRevision(await stat(path, { bigint: true }));
964
- const buffer = await readFile(path, { signal });
2491
+ let preparation = this.migrationPreparations.get(id);
2492
+ if (preparation === void 0 || preparation.sourcePath !== selected.sourcePath || preparation.sourceRevision !== sourceRevision) {
2493
+ const controller = new AbortController();
2494
+ const promise = this.loadStoredMigration(id, selected, sourceRevision, controller.signal);
2495
+ preparation = {
2496
+ sourcePath: selected.sourcePath,
2497
+ sourceRevision,
2498
+ controller,
2499
+ promise,
2500
+ settled: false,
2501
+ waiters: 0
2502
+ };
2503
+ this.migrationPreparations.set(id, preparation);
2504
+ const created = preparation;
2505
+ const release = () => {
2506
+ created.settled = true;
2507
+ if (this.migrationPreparations.get(id) === created) this.migrationPreparations.delete(id);
2508
+ };
2509
+ promise.then(release, release);
2510
+ }
965
2511
  signal?.throwIfAborted();
966
- const after = fileRevision(await stat(path, { bigint: true }));
967
- if (before === after) return {
968
- buffer,
969
- revision: after
970
- };
2512
+ return this.waitForPreparation(id, preparation, signal);
2513
+ }
2514
+ if (selected.sourceVersion > SESSION_FORMAT_VERSION) {
2515
+ /* v8 ignore else -- a readable future header is rejected inside readGenerationHeader. */
2516
+ if (await this.readGenerationHeader(selected, id, signal) === void 0) throw new SessionPersistenceCorruptionError(`session "${id}": stored log has a malformed header (raw log: ${selected.sourcePath})`, { cause: /* @__PURE__ */ new Error("malformed Session header") });
2517
+ /* v8 ignore next -- readGenerationHeader rejects every future version. */
2518
+ throw new SessionFormatUnsupportedError(`${sessionFormatVersionRefusal(id, selected.sourceVersion)} (raw log: ${selected.sourcePath})`, {
2519
+ kind: "jsonl",
2520
+ path: selected.sourcePath
2521
+ });
2522
+ }
2523
+ const probe = fileRevision(await stat(selected.sourcePath, { bigint: true }));
2524
+ const memoized = this.coldLogMemo.get(id);
2525
+ if (memoized?.status === "current" && memoized.revision === probe) {
2526
+ this.coldLogMemo.delete(id);
2527
+ this.coldLogMemo.set(id, memoized);
2528
+ return memoized;
2529
+ }
2530
+ const current = await readStableJsonlFile(selected.sourcePath, signal);
2531
+ return this.decodeStoredLog(selected.sourcePath, id, current.bytes, fileRevision(current.identity), signal);
2532
+ }
2533
+ /** Probe the memo and otherwise decode one historical generation under backend cancellation. */
2534
+ async loadStoredMigration(id, selected, sourceRevision, signal) {
2535
+ signal.throwIfAborted();
2536
+ const memoized = this.coldLogMemo.get(id);
2537
+ if (memoized?.status === "prepared" && memoized.revision === sourceRevision) {
2538
+ this.coldLogMemo.delete(id);
2539
+ this.coldLogMemo.set(id, memoized);
2540
+ return memoized;
2541
+ }
2542
+ return this.prepareStoredMigration(id, selected, signal);
2543
+ }
2544
+ /** Await shared preparation for one caller and abort it only after its last waiter leaves. */
2545
+ async waitForPreparation(id, preparation, signal) {
2546
+ preparation.waiters += 1;
2547
+ try {
2548
+ return await waitWithAbort(preparation.promise, signal);
2549
+ } finally {
2550
+ preparation.waiters -= 1;
2551
+ if (preparation.waiters === 0 && !preparation.settled) {
2552
+ /* v8 ignore else -- a newer selected source may already own this id's preparation slot. */
2553
+ if (this.migrationPreparations.get(id) === preparation) this.migrationPreparations.delete(id);
2554
+ preparation.controller.abort();
2555
+ }
2556
+ }
2557
+ }
2558
+ /** Decode one historical generation without publishing a successor. */
2559
+ async prepareStoredMigration(id, selected, signal) {
2560
+ let prepared;
2561
+ try {
2562
+ prepared = await prepareJsonlMigration({
2563
+ sourcePath: selected.sourcePath,
2564
+ sourceVersion: selected.sourceVersion,
2565
+ currentPath: selected.currentPath,
2566
+ compression: this.compression,
2567
+ format: this.generationFormat,
2568
+ verifyCurrentFile: verifyCurrentGenerationInWorker,
2569
+ validateHistoricalHeader: (headerValue) => this.validateSourceIdentity(selected, headerValue, id, signal),
2570
+ signal
2571
+ });
2572
+ } catch (error) {
2573
+ throw this.generationFailure(id, selected, error);
2574
+ }
2575
+ const meta = this.currentHeader(prepared.artifact.header);
2576
+ assertStoredId(id, meta);
2577
+ const events = prepared.artifact.events;
2578
+ validateStoredEvents(meta, events, {
2579
+ kind: "jsonl",
2580
+ path: selected.sourcePath
2581
+ });
2582
+ const stored = {
2583
+ status: "prepared",
2584
+ meta,
2585
+ ...freezeStoredEvents(events),
2586
+ tornTruncateTo: void 0,
2587
+ recoveredTail: [],
2588
+ inheritedEventCount: SessionLogOffset(prepared.artifact.inheritedEventCount),
2589
+ revision: fileRevision(prepared.sourceIdentity),
2590
+ publication: {
2591
+ source: selected,
2592
+ value: prepared
2593
+ }
2594
+ };
2595
+ this.memoizeStoredLog(id, stored);
2596
+ return stored;
2597
+ }
2598
+ /** Publish a prepared historical log before granting write access. */
2599
+ async publishStoredMigration(id, stored) {
2600
+ const migration = stored.publication;
2601
+ let identity;
2602
+ try {
2603
+ identity = await migration.value.publish();
2604
+ } catch (error) {
2605
+ /* v8 ignore else -- a newer preparation may have replaced this stale cache entry. */
2606
+ if (this.coldLogMemo.get(id) === stored) this.coldLogMemo.delete(id);
2607
+ throw this.generationFailure(id, migration.source, error);
971
2608
  }
2609
+ const published = {
2610
+ status: "current",
2611
+ meta: stored.meta,
2612
+ eventState: stored.eventState,
2613
+ events: stored.events,
2614
+ tornTruncateTo: stored.tornTruncateTo,
2615
+ recoveredTail: stored.recoveredTail,
2616
+ inheritedEventCount: stored.inheritedEventCount,
2617
+ revision: fileRevision(identity)
2618
+ };
2619
+ this.memoizeStoredLog(id, published);
2620
+ return published;
2621
+ }
2622
+ /** Translate generation-layer failures into the persistence seam's error vocabulary. */
2623
+ generationFailure(id, selected, error) {
2624
+ if (error instanceof JsonlGenerationUnsupportedMigrationError) return new SessionFormatUnsupportedError(`${error.message}; source v${error.fromVersion} artifact remains unchanged (raw log: ${selected.sourcePath})`, {
2625
+ kind: "jsonl",
2626
+ path: selected.sourcePath
2627
+ });
2628
+ if (error instanceof JsonlGenerationSourceChangedError) return error;
2629
+ if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError || isErrnoException(error) || error instanceof DOMException && error.name === "AbortError") return error;
2630
+ return new SessionPersistenceCorruptionError(`session "${id}": stored log is corrupt: ${String(error)} (raw log: ${selected.sourcePath})`, { cause: error });
972
2631
  }
973
2632
  /**
974
- * Read a stored prefix and convert torn-tail state to the opaque marker the
975
- * coordinator can round-trip without knowing the physical encoding.
2633
+ * Read, parse, and validate one stored log as the current logical prefix.
2634
+ * @param path - the artifact file to read.
2635
+ * @param expectedId - the session identity the artifact must carry.
2636
+ * @param signal - optional cancellation for the stat/read/decode work.
2637
+ * @returns the validated stored log with any torn-tail truncation point.
976
2638
  */
977
- async readPrefix(path, expectedId, signal) {
978
- const { buffer, revision } = await this.readStableFile(path, signal);
979
- let prefix;
2639
+ async readStoredLog(path, expectedId, signal) {
2640
+ signal?.throwIfAborted();
2641
+ const probe = fileRevision(await stat(path, { bigint: true }));
2642
+ const memoized = this.coldLogMemo.get(expectedId);
2643
+ if (memoized?.status === "current" && memoized.revision === probe) {
2644
+ this.coldLogMemo.delete(expectedId);
2645
+ this.coldLogMemo.set(expectedId, memoized);
2646
+ return memoized;
2647
+ }
2648
+ const { bytes, identity } = await readStableJsonlFile(path, signal);
2649
+ return this.decodeStoredLog(path, expectedId, bytes, fileRevision(identity), signal);
2650
+ }
2651
+ /** Decode and memoize one already-stable current physical snapshot. */
2652
+ async decodeStoredLog(path, expectedId, buffer, revision, signal) {
2653
+ let parsed;
980
2654
  try {
981
- if (this.compression === "zstd") prefix = await this.readZstdPrefix(buffer, signal);
2655
+ if (this.compression === "zstd") parsed = await this.readZstdPrefix(buffer, signal);
982
2656
  else {
983
2657
  signal?.throwIfAborted();
984
2658
  const { meta, inheritedEventCount, events, committedBytes } = scanLog(buffer);
985
2659
  signal?.throwIfAborted();
986
- prefix = {
2660
+ parsed = {
987
2661
  meta,
988
2662
  inheritedEventCount,
989
2663
  events,
990
- ...committedBytes < buffer.byteLength ? { tornMarker: {
991
- truncateTo: committedBytes,
992
- recoveredEvents: []
993
- } } : {}
2664
+ tornTruncateTo: committedBytes < buffer.byteLength ? committedBytes : void 0,
2665
+ recoveredTail: []
994
2666
  };
995
2667
  }
996
2668
  } catch (error) {
997
- if (error instanceof SessionFormatUnsupportedError && error.location === void 0) throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, {
2669
+ signal?.throwIfAborted();
2670
+ if (error instanceof SessionFormatUnsupportedError) throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, {
998
2671
  kind: "jsonl",
999
2672
  path
1000
2673
  });
1001
- throw error;
2674
+ throw new SessionPersistenceCorruptionError(`session "${expectedId}": stored log is corrupt: ${String(error)} (raw log: ${path})`, { cause: error });
1002
2675
  }
1003
2676
  signal?.throwIfAborted();
1004
- await this.assertStoredIdentity(path, prefix.meta, expectedId, signal);
2677
+ await this.assertStoredIdentity(path, SESSION_FORMAT_VERSION, parsed.meta, expectedId, signal);
1005
2678
  signal?.throwIfAborted();
1006
- return {
1007
- ...prefix,
2679
+ assertStoredId(expectedId, parsed.meta);
2680
+ const location = this.locate(parsed.meta);
2681
+ validateStoredEvents(parsed.meta, parsed.events, location);
2682
+ const { events, ...rest } = parsed;
2683
+ const stored = {
2684
+ status: "current",
2685
+ ...rest,
2686
+ ...freezeStoredEvents(events),
1008
2687
  revision
1009
2688
  };
2689
+ this.memoizeStoredLog(expectedId, stored);
2690
+ return stored;
2691
+ }
2692
+ /** Insert one parsed log into the bounded handoff cache. */
2693
+ memoizeStoredLog(id, stored) {
2694
+ this.coldLogMemo.delete(id);
2695
+ this.coldLogMemo.set(id, stored);
2696
+ for (const oldest of this.coldLogMemo.keys()) {
2697
+ if (this.coldLogMemo.size <= COLD_LOG_MEMO_MAX_ENTRIES) break;
2698
+ this.coldLogMemo.delete(oldest);
2699
+ }
2700
+ }
2701
+ /**
2702
+ * Resolve a session's current-generation log path.
2703
+ * @param id - the stored session to locate.
2704
+ * @param signal - optional cancellation for the directory scans.
2705
+ * @returns the current artifact path, or `undefined` while only a historical generation exists.
2706
+ */
2707
+ async resolveCurrentLog(id, signal) {
2708
+ await this.ensureRootEncoding();
2709
+ signal?.throwIfAborted();
2710
+ const selected = await this.findLog(id, signal);
2711
+ if (selected === void 0) return void 0;
2712
+ if (selected.sourceVersion === SESSION_FORMAT_VERSION) return selected.sourcePath;
2713
+ if (selected.sourceVersion < SESSION_FORMAT_VERSION) return void 0;
2714
+ throw new SessionFormatUnsupportedError(`${sessionFormatVersionRefusal(id, selected.sourceVersion)} (raw log: ${selected.sourcePath})`, {
2715
+ kind: "jsonl",
2716
+ path: selected.sourcePath
2717
+ });
2718
+ }
2719
+ /**
2720
+ * Durably append one validated batch; lazily materializes on the first write.
2721
+ * @param header - the session's stored header.
2722
+ * @param events - the validated contiguous batch, in seq order.
2723
+ * @param isMaterialized - whether the session already has a durable artifact.
2724
+ * @param inheritedEventCount - the exact fork-inherited prefix length written into a materializing header line.
2725
+ */
2726
+ async persistBatch(header, events, isMaterialized, inheritedEventCount) {
2727
+ this.coldLogMemo.delete(header.id);
2728
+ await this.ensureRootEncoding();
2729
+ if (isMaterialized) await this.appendLines(header, events);
2730
+ else {
2731
+ await this.materialize(header, inheritedEventCount, events);
2732
+ this.tracker.materialized(header.id);
2733
+ }
2734
+ }
2735
+ /**
2736
+ * Materialize a header-only artifact for an explicitly durable empty session.
2737
+ * @param header - the session's stored header.
2738
+ * @param inheritedEventCount - the exact fork-inherited prefix length written into the header line.
2739
+ */
2740
+ async persistHeader(header, inheritedEventCount) {
2741
+ this.coldLogMemo.delete(header.id);
2742
+ await this.ensureRootEncoding();
2743
+ await this.materialize(header, inheritedEventCount, []);
2744
+ this.tracker.materialized(header.id);
2745
+ }
2746
+ /**
2747
+ * Truncate a torn physical tail durably before this session's first new append.
2748
+ * @param header - the session's stored header.
2749
+ * @param truncateTo - the byte offset the artifact is truncated to.
2750
+ */
2751
+ async truncateTornTail(header, truncateTo) {
2752
+ this.coldLogMemo.delete(header.id);
2753
+ await this.repair(header, truncateTo);
2754
+ this.ctx.logger.warn(`${this.name}: session "${header.id}" recovered from a torn tail; incomplete tail bytes were discarded`);
2755
+ }
2756
+ /**
2757
+ * Whether this process still tracks a created-but-unmaterialized session.
2758
+ * @param id - the session to test.
2759
+ * @returns true while the pending entry exists.
2760
+ */
2761
+ hasPendingSession(id) {
2762
+ return this.tracker.hasPending(id);
2763
+ }
2764
+ /**
2765
+ * Release one handle's backend bookkeeping on close.
2766
+ * @param handle - the closing handle.
2767
+ * @param materialized - whether the session reached durable storage.
2768
+ */
2769
+ releaseHandle(handle, materialized) {
2770
+ this.tracker.release(handle, materialized);
2771
+ }
2772
+ /**
2773
+ * Acquire the session directory's kernel write lock; the kernel holds it
2774
+ * until the handle's close releases the descriptor, including on process death.
2775
+ * @param id - the session the lock guards.
2776
+ * @param cwd - header cwd used to derive the directory for a fresh session.
2777
+ * @param dir - the resolved directory of an existing artifact, when known.
2778
+ * @returns the held lock.
2779
+ */
2780
+ acquireLease(id, cwd, dir = sessionDir(this.root, cwd, id)) {
2781
+ return SessionWriteLease.acquire(dir, id);
2782
+ }
2783
+ /**
2784
+ * Acquire the cross-process write lock for a materializing created session,
2785
+ * called by its handle immediately before the first log bytes publish.
2786
+ * @param header - the session's stored header (its cwd derives the directory).
2787
+ * @returns the held lock.
2788
+ */
2789
+ async acquireWriteLease(header) {
2790
+ await this.rejectOppositeArtifact(header.cwd, header.id);
2791
+ return this.acquireLease(header.id, header.cwd);
1010
2792
  }
1011
2793
  /** Decode complete frames and retain complete JSONL records from a torn final frame. */
1012
2794
  async readZstdPrefix(buffer, signal) {
@@ -1044,7 +2826,9 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1044
2826
  return {
1045
2827
  meta: prefix.meta,
1046
2828
  inheritedEventCount: prefix.inheritedEventCount,
1047
- events: prefix.events
2829
+ events: prefix.events,
2830
+ tornTruncateTo: void 0,
2831
+ recoveredTail: []
1048
2832
  };
1049
2833
  }
1050
2834
  let recoveredPlaintext = Buffer.alloc(0);
@@ -1057,16 +2841,13 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1057
2841
  }
1058
2842
  signal?.throwIfAborted();
1059
2843
  scanner.write(recoveredPlaintext);
1060
- const recoveredPrefix = scanner.finish();
1061
- signal?.throwIfAborted();
2844
+ const prefix = scanner.finish();
1062
2845
  return {
1063
- meta: recoveredPrefix.meta,
1064
- inheritedEventCount: recoveredPrefix.inheritedEventCount,
1065
- events: recoveredPrefix.events,
1066
- tornMarker: {
1067
- truncateTo: tornStart,
1068
- recoveredEvents: recoveredPrefix.events.slice(complete.eventCount)
1069
- }
2846
+ meta: prefix.meta,
2847
+ inheritedEventCount: prefix.inheritedEventCount,
2848
+ events: prefix.events,
2849
+ tornTruncateTo: tornStart,
2850
+ recoveredTail: prefix.events.slice(complete.eventCount)
1070
2851
  };
1071
2852
  } catch (error) {
1072
2853
  /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
@@ -1076,52 +2857,6 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1076
2857
  decoder.close();
1077
2858
  }
1078
2859
  }
1079
- /** Durably append a batch, lazily materializing the file when not yet present. */
1080
- async appendBatch(storage, events, isMaterialized) {
1081
- await this.ensureRootEncoding();
1082
- if (isMaterialized) await this.appendLines(storage.meta, events);
1083
- else await this.materialize(storage, events);
1084
- }
1085
- /** Materialize a header-only JSONL artifact for an explicitly durable empty session. */
1086
- async materializeHeader(storage) {
1087
- await this.materialize(storage, []);
1088
- }
1089
- /**
1090
- * Make a crash repair durable: truncate a torn tail, restore complete events
1091
- * decoded from it, then append synthetic closers. Two fsync'd steps — the seam
1092
- * does not require this to be atomic.
1093
- */
1094
- async commitRepair(storage, tornMarker, closers) {
1095
- const { meta } = storage;
1096
- if (tornMarker !== void 0) await this.repair(meta, tornMarker.truncateTo);
1097
- const repairedEvents = [...tornMarker?.recoveredEvents ?? [], ...closers];
1098
- if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents);
1099
- if (tornMarker !== void 0) this.ctx.logger.warn(`${this.name}: session "${meta.id}" recovered from a torn tail; incomplete tail bytes were discarded`);
1100
- }
1101
- /** List valid unique stored sessions' metadata (header line only — no full-log parse). */
1102
- async list(signal) {
1103
- return (await this.listArtifacts(signal)).map((artifact) => artifact.header);
1104
- }
1105
- /** List metadata plus a stat-derived identity for each append-only log. */
1106
- async listSnapshots(signal) {
1107
- const snapshots = [];
1108
- for (const artifact of await this.listArtifacts(signal)) {
1109
- signal?.throwIfAborted();
1110
- try {
1111
- const identity = await stat(artifact.path, { bigint: true });
1112
- signal?.throwIfAborted();
1113
- snapshots.push({
1114
- header: artifact.header,
1115
- revision: fileRevision(identity)
1116
- });
1117
- } catch (error) {
1118
- signal?.throwIfAborted();
1119
- if (!isENOENT(error)) throw error;
1120
- }
1121
- }
1122
- signal?.throwIfAborted();
1123
- return snapshots;
1124
- }
1125
2860
  async listArtifacts(signal) {
1126
2861
  signal?.throwIfAborted();
1127
2862
  await this.ensureRootEncoding();
@@ -1132,40 +2867,86 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1132
2867
  signal?.throwIfAborted();
1133
2868
  for (const dir of await this.listSessionDirs(project, signal)) {
1134
2869
  signal?.throwIfAborted();
1135
- const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`);
1136
- const oppositeExists = await this.exists(opposite);
1137
- signal?.throwIfAborted();
1138
- if (oppositeExists) throw this.encodingMismatch(opposite);
1139
- const path = join(dir, `session${logSuffix(this.compression)}`);
1140
- const pathExists = await this.exists(path);
1141
- signal?.throwIfAborted();
1142
- if (!pathExists) continue;
1143
- const first = this.compression === "zstd" ? await this.readFirstZstdLine(path, signal) : await this.readFirstLine(path, signal);
1144
- signal?.throwIfAborted();
1145
- if (first === void 0) continue;
1146
- const meta = parseHeaderMeta(first);
1147
- if (meta === void 0) continue;
1148
- await this.assertStoredIdentity(path, meta, void 0, signal);
1149
- signal?.throwIfAborted();
1150
- if (ids.has(meta.id)) throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`);
1151
- ids.add(meta.id);
2870
+ const selected = await this.resolveGenerationInDirectory(dir, signal);
2871
+ if (selected === void 0) continue;
2872
+ let header;
2873
+ try {
2874
+ header = await this.readGenerationHeader(selected, void 0, signal);
2875
+ } catch (error) {
2876
+ if (error instanceof SessionFormatUnsupportedError) continue;
2877
+ throw error;
2878
+ }
2879
+ if (header === void 0) continue;
2880
+ if (ids.has(header.id)) throw new Error(`duplicate JSONL session id "${header.id}" appears in multiple project directories`);
2881
+ ids.add(header.id);
1152
2882
  artifacts.push({
1153
- header: meta,
1154
- path
2883
+ header,
2884
+ path: selected.sourcePath
1155
2885
  });
1156
2886
  }
1157
2887
  }
1158
2888
  signal?.throwIfAborted();
1159
2889
  return artifacts;
1160
2890
  }
2891
+ /** Read and translate one selected generation header without inspecting its body. */
2892
+ async readGenerationHeader(selected, expectedId, signal) {
2893
+ let first;
2894
+ try {
2895
+ first = this.compression === "zstd" ? await this.readFirstZstdLine(selected.sourcePath, signal) : await this.readFirstLine(selected.sourcePath, signal);
2896
+ } catch (error) {
2897
+ signal?.throwIfAborted();
2898
+ if (isENOENT(error)) return void 0;
2899
+ throw error;
2900
+ }
2901
+ signal?.throwIfAborted();
2902
+ if (first === void 0) return void 0;
2903
+ let value;
2904
+ try {
2905
+ value = JSON.parse(first);
2906
+ } catch {
2907
+ return;
2908
+ }
2909
+ assertNoRetiredHeaderFields(value);
2910
+ const result = sessionFormatCatalog.readHeader(value);
2911
+ if ("storedVersion" in result && result.storedVersion !== selected.sourceVersion) throw new Error(`session generation filename identifies v${selected.sourceVersion}, but its header identifies v${result.storedVersion}`);
2912
+ if (result.status === "unsupported") {
2913
+ const physicalId = String(value.id);
2914
+ let reason = result.reason;
2915
+ /* v8 ignore else -- released historical header migrations cannot refuse after physical decoding. */
2916
+ if (result.storedVersion > SESSION_FORMAT_VERSION) reason = sessionFormatVersionRefusal(physicalId, result.storedVersion);
2917
+ throw new SessionFormatUnsupportedError(`${reason} (raw log: ${selected.sourcePath})`, {
2918
+ kind: "jsonl",
2919
+ path: selected.sourcePath
2920
+ });
2921
+ }
2922
+ if (result.status === "malformed") return void 0;
2923
+ const header = this.currentHeader(result.header);
2924
+ await this.assertStoredIdentity(selected.sourcePath, selected.sourceVersion, header, expectedId, signal);
2925
+ return header;
2926
+ }
2927
+ /** Convert format-catalog string identities to current branded Session metadata. */
2928
+ currentHeader(header) {
2929
+ /* v8 ignore next 3 -- readable catalog results are restored to its configured current version. */
2930
+ if (header.version !== SESSION_FORMAT_VERSION) throw new Error(`format catalog returned non-current logical header v${header.version}`);
2931
+ return {
2932
+ version: SESSION_FORMAT_VERSION,
2933
+ id: SessionId(header.id),
2934
+ createdAt: header.createdAt,
2935
+ ...header.cwd === void 0 ? {} : { cwd: header.cwd },
2936
+ ...header.parentSession === void 0 ? {} : { parentSession: SessionId(header.parentSession) },
2937
+ isSeeded: header.isSeeded,
2938
+ ...header.origin === void 0 ? {} : { origin: header.origin },
2939
+ delegationDepth: header.delegationDepth,
2940
+ ...header.agentPreset === void 0 ? {} : { agentPreset: header.agentPreset }
2941
+ };
2942
+ }
1161
2943
  /** Atomically write the header line + first batch (temp-write, fsync, publish). */
1162
- async materialize(storage, events) {
1163
- const { meta } = storage;
2944
+ async materialize(meta, inheritedEventCount, events) {
1164
2945
  const project = projectDir(this.root, meta.cwd);
1165
2946
  const dir = sessionDir(this.root, meta.cwd, meta.id);
1166
2947
  const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression);
1167
2948
  await this.rejectOppositeArtifact(meta.cwd, meta.id);
1168
- const content = await this.encodeMaterialization(storage, events);
2949
+ const content = await this.encodeMaterialization(meta, inheritedEventCount, events);
1169
2950
  /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
1170
2951
  if (process.platform === "win32") await this.materializeWin32(project, dir, finalPath, meta.id, content);
1171
2952
  else await this.materializePosix(project, dir, finalPath, meta.id, content);
@@ -1219,8 +3000,8 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1219
3000
  }
1220
3001
  /* v8 ignore stop */
1221
3002
  async rejectExistingLog(finalPath, id) {
1222
- /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
1223
- if (await this.exists(finalPath)) throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`);
3003
+ /* v8 ignore next 3 -- create guards collisions before materialize; this is a TOCTOU backstop */
3004
+ if (await this.resolveGenerationInDirectory(dirname(finalPath)) !== void 0) throw new Error(`refusing to materialize "${id}": a log already exists on disk (open it instead)`);
1224
3005
  }
1225
3006
  async writeSyncedTempFile(finalPath, content) {
1226
3007
  const tmp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
@@ -1234,10 +3015,10 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1234
3015
  return tmp;
1235
3016
  }
1236
3017
  /** Encode the header and first batch without combining their frame boundaries. */
1237
- async encodeMaterialization(storage, events) {
1238
- const header = JSON.stringify(toHeaderLine(storage.meta, storage.inheritedEventCount)) + "\n";
3018
+ async encodeMaterialization(meta, inheritedEventCount, events) {
3019
+ const header = JSON.stringify(toHeaderLine(meta, meta.isSeeded ? inheritedEventCount : void 0)) + "\n";
1239
3020
  if (events.length === 0) return this.compression === "none" ? header : compressZstdFrame(header);
1240
- const body = eventLines(events, this.packChunks) + "\n";
3021
+ const body = eventLines(events) + "\n";
1241
3022
  if (this.compression === "none") return header + body;
1242
3023
  const headerFrame = await compressZstdFrame(header);
1243
3024
  const eventFrame = await compressZstdFrame(body);
@@ -1245,7 +3026,7 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1245
3026
  }
1246
3027
  /** Encode one durable append batch in the configured physical representation. */
1247
3028
  async encodeEventBatch(events) {
1248
- const body = eventLines(events, this.packChunks) + "\n";
3029
+ const body = eventLines(events) + "\n";
1249
3030
  return this.compression === "zstd" ? compressZstdFrame(body) : body;
1250
3031
  }
1251
3032
  /** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
@@ -1378,7 +3159,40 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1378
3159
  await handle.close();
1379
3160
  }
1380
3161
  }
1381
- /** Find the unique physical log for an id across every project directory. */
3162
+ /** Select the numerically highest canonical generation in one Session directory. */
3163
+ async resolveGenerationInDirectory(dir, signal) {
3164
+ signal?.throwIfAborted();
3165
+ let entries;
3166
+ try {
3167
+ entries = await readdir(dir, { withFileTypes: true });
3168
+ } catch (error) {
3169
+ if (isENOENT(error)) return void 0;
3170
+ throw error;
3171
+ }
3172
+ signal?.throwIfAborted();
3173
+ const generations = [];
3174
+ const opposite = [];
3175
+ for (const entry of entries) {
3176
+ const version = parseGenerationLogFilename(entry.name, this.compression);
3177
+ if (version !== void 0) {
3178
+ generations.push({
3179
+ path: join(dir, entry.name),
3180
+ version
3181
+ });
3182
+ continue;
3183
+ }
3184
+ if (parseGenerationLogFilename(entry.name, this.oppositeCompression()) !== void 0) opposite.push(join(dir, entry.name));
3185
+ }
3186
+ if (opposite.length > 0) throw this.encodingMismatch(opposite[0]);
3187
+ const latest = generations.sort((left, right) => right.version - left.version)[0];
3188
+ if (latest === void 0) return void 0;
3189
+ return {
3190
+ sourcePath: latest.path,
3191
+ sourceVersion: latest.version,
3192
+ currentPath: join(dir, generationLogFilename(sessionFormatCatalog.currentVersion, this.compression))
3193
+ };
3194
+ }
3195
+ /** Find the unique authoritative generation for an id across project directories. */
1382
3196
  async findLog(id, signal) {
1383
3197
  const matches = [];
1384
3198
  for (const project of await this.listProjectDirs(signal)) {
@@ -1386,14 +3200,8 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1386
3200
  await this.rejectLegacyFlatArtifact(project, id, signal);
1387
3201
  signal?.throwIfAborted();
1388
3202
  const dir = join(project, encodeSegment(id));
1389
- const path = join(dir, `session${logSuffix(this.compression)}`);
1390
- const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`);
1391
- const oppositeExists = await this.exists(opposite);
1392
- signal?.throwIfAborted();
1393
- if (oppositeExists) throw this.encodingMismatch(opposite);
1394
- const pathExists = await this.exists(path);
1395
- signal?.throwIfAborted();
1396
- if (pathExists) matches.push(path);
3203
+ const selected = await this.resolveGenerationInDirectory(dir, signal);
3204
+ if (selected !== void 0) matches.push(selected);
1397
3205
  }
1398
3206
  if (matches.length > 1) throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`);
1399
3207
  signal?.throwIfAborted();
@@ -1409,18 +3217,24 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1409
3217
  }
1410
3218
  }
1411
3219
  /** Reject metadata that does not identify the selected physical log. */
1412
- async assertStoredIdentity(path, meta, expectedId, signal) {
3220
+ async assertStoredIdentity(path, storedVersion, meta, expectedId, signal) {
1413
3221
  signal?.throwIfAborted();
1414
3222
  if (expectedId !== void 0 && meta.id !== expectedId) throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`);
1415
3223
  let expectedPath;
1416
3224
  try {
1417
- expectedPath = logPath(this.root, meta.cwd, meta.id, this.compression);
3225
+ expectedPath = generationLogPath(this.root, meta.cwd, meta.id, storedVersion, this.compression);
1418
3226
  } catch (error) {
1419
3227
  throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error });
1420
3228
  }
1421
3229
  if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`);
1422
3230
  signal?.throwIfAborted();
1423
3231
  }
3232
+ /** Validate a supported historical header against the selected source path. */
3233
+ validateSourceIdentity(selected, headerValue, expectedId, signal) {
3234
+ const result = sessionFormatCatalog.readHeader(headerValue);
3235
+ if (result.status !== "current" && result.status !== "migration-required") return;
3236
+ return this.assertStoredIdentity(selected.sourcePath, selected.sourceVersion, this.currentHeader(result.header), expectedId, signal);
3237
+ }
1424
3238
  /**
1425
3239
  * Whether two path spellings resolve to the same physical file. This admits
1426
3240
  * case aliases on case-insensitive filesystems without weakening identity
@@ -1468,8 +3282,8 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1468
3282
  }
1469
3283
  async checkRootEncoding() {
1470
3284
  for (const project of await this.listProjectDirs()) for (const dir of await this.listSessionDirs(project)) {
1471
- const incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`);
1472
- if (await this.exists(incompatible)) throw this.encodingMismatch(incompatible);
3285
+ const incompatible = await this.findOppositeGenerationInDirectory(dir);
3286
+ if (incompatible !== void 0) throw this.encodingMismatch(incompatible);
1473
3287
  }
1474
3288
  }
1475
3289
  async rejectLegacyFlatArtifact(project, id, signal) {
@@ -1483,8 +3297,28 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1483
3297
  }
1484
3298
  }
1485
3299
  async rejectOppositeArtifact(cwd, id) {
1486
- const path = logPath(this.root, cwd, id, this.oppositeCompression());
1487
- if (await this.exists(path)) throw this.encodingMismatch(path);
3300
+ const path = await this.findOppositeGenerationInDirectory(sessionDir(this.root, cwd, id));
3301
+ if (path !== void 0) throw this.encodingMismatch(path);
3302
+ }
3303
+ /** Return the highest canonical generation encoded with the other configured suffix. */
3304
+ async findOppositeGenerationInDirectory(dir) {
3305
+ let entries;
3306
+ try {
3307
+ entries = await readdir(dir, { withFileTypes: true });
3308
+ } catch (error) {
3309
+ if (isENOENT(error)) return void 0;
3310
+ throw error;
3311
+ }
3312
+ const generations = [];
3313
+ for (const entry of entries) {
3314
+ const version = parseGenerationLogFilename(entry.name, this.oppositeCompression());
3315
+ if (version !== void 0) generations.push({
3316
+ name: entry.name,
3317
+ version
3318
+ });
3319
+ }
3320
+ const latest = generations.sort((left, right) => right.version - left.version)[0];
3321
+ return latest === void 0 ? void 0 : join(dir, latest.name);
1488
3322
  }
1489
3323
  oppositeCompression() {
1490
3324
  return this.compression === "zstd" ? "none" : "zstd";
@@ -1526,4 +3360,4 @@ var JsonlSessionPersistence = class extends SessionPersistence {
1526
3360
  }
1527
3361
  };
1528
3362
  //#endregion
1529
- export { JsonlCompressionSchema, JsonlSessionPersistence, JsonlSessionPersistence as default };
3363
+ export { JsonlCompressionSchema, JsonlSessionPersistence as default };