@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/README.i18n.yaml +2 -2
- package/README.md +24 -17
- package/README.zh.md +24 -17
- package/lib/index.js +2313 -479
- package/lib/types/format.d.ts +53 -31
- package/lib/types/generation.d.ts +163 -0
- package/lib/types/index.d.ts +176 -89
- package/lib/types/lease.d.ts +57 -0
- package/lib/types/migration-verifier.d.ts +15 -0
- package/lib/types/storage.d.ts +249 -0
- package/lib/types/testing/generation.d.ts +8 -0
- package/lib/types/win32.d.ts +18 -0
- package/lib/types/worker.d.ts +3 -0
- package/lib/worker.cjs +1235 -0
- package/package.json +13 -6
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
942
|
+
* @returns the current Session format generation path.
|
|
163
943
|
*/
|
|
164
944
|
function logPath(root, cwd, id, compression) {
|
|
165
|
-
return
|
|
945
|
+
return generationLogPath(root, cwd, id, SESSION_FORMAT_VERSION, compression);
|
|
166
946
|
}
|
|
167
947
|
/**
|
|
168
|
-
* Serialize
|
|
169
|
-
*
|
|
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
|
|
179
|
-
return
|
|
953
|
+
function eventLines(events) {
|
|
954
|
+
return events.map(eventLine).join("\n");
|
|
180
955
|
}
|
|
181
956
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
313
|
-
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 =
|
|
1091
|
+
decoded = JSON.parse(line.toString("utf8"));
|
|
323
1092
|
} catch {
|
|
324
|
-
|
|
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
|
|
1099
|
+
if (typeof decoded === "object" && decoded !== null && decoded.type === "turn/end") throw this.issue;
|
|
329
1100
|
return;
|
|
330
1101
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
712
|
-
|
|
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
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
|
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
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
778
|
-
*
|
|
779
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
813
|
-
*
|
|
814
|
-
*
|
|
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
|
-
|
|
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.
|
|
848
|
-
preparedSessionCacheSize,
|
|
849
|
-
writeBatchMaxDelayMs
|
|
850
|
-
});
|
|
2302
|
+
this.tracker.install(ctx);
|
|
851
2303
|
}
|
|
852
|
-
/**
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
return this.
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
/**
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
889
|
-
if (
|
|
890
|
-
|
|
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
|
-
*
|
|
894
|
-
*
|
|
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
|
-
|
|
897
|
-
|
|
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
|
|
901
|
-
if (
|
|
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(
|
|
904
|
-
signal?.throwIfAborted();
|
|
905
|
-
return
|
|
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
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
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
|
|
926
|
-
signal?.
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
|
975
|
-
*
|
|
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
|
|
978
|
-
|
|
979
|
-
|
|
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")
|
|
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
|
-
|
|
2660
|
+
parsed = {
|
|
987
2661
|
meta,
|
|
988
2662
|
inheritedEventCount,
|
|
989
2663
|
events,
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
recoveredEvents: []
|
|
993
|
-
} } : {}
|
|
2664
|
+
tornTruncateTo: committedBytes < buffer.byteLength ? committedBytes : void 0,
|
|
2665
|
+
recoveredTail: []
|
|
994
2666
|
};
|
|
995
2667
|
}
|
|
996
2668
|
} catch (error) {
|
|
997
|
-
|
|
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,
|
|
2677
|
+
await this.assertStoredIdentity(path, SESSION_FORMAT_VERSION, parsed.meta, expectedId, signal);
|
|
1005
2678
|
signal?.throwIfAborted();
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
|
1061
|
-
signal?.throwIfAborted();
|
|
2844
|
+
const prefix = scanner.finish();
|
|
1062
2845
|
return {
|
|
1063
|
-
meta:
|
|
1064
|
-
inheritedEventCount:
|
|
1065
|
-
events:
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
if (
|
|
1146
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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 --
|
|
1223
|
-
if (await this.
|
|
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(
|
|
1238
|
-
const header = JSON.stringify(toHeaderLine(
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
1390
|
-
|
|
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 =
|
|
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 =
|
|
1472
|
-
if (
|
|
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 =
|
|
1487
|
-
if (
|
|
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
|
|
3363
|
+
export { JsonlCompressionSchema, JsonlSessionPersistence as default };
|