@x1a0f3n9/dsh-session-persistence-jsonl 0.1.5-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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +170 -0
- package/README.zh.md +170 -0
- package/lib/index.js +3726 -0
- package/lib/types/format.d.ts +209 -0
- package/lib/types/generation.d.ts +163 -0
- package/lib/types/history-suffix.d.ts +16 -0
- package/lib/types/index.d.ts +304 -0
- package/lib/types/lease.d.ts +57 -0
- package/lib/types/migration-verifier.d.ts +15 -0
- package/lib/types/storage.d.ts +263 -0
- package/lib/types/testing/generation.d.ts +8 -0
- package/lib/types/win32.d.ts +48 -0
- package/lib/types/worker.d.ts +3 -0
- package/lib/types/zstd-private-decoder.d.ts +33 -0
- package/lib/types/zstd-public-decoder.d.ts +15 -0
- package/lib/types/zstd.d.ts +68 -0
- package/lib/worker.cjs +1246 -0
- package/package.json +50 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3726 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { SessionFormatUnsupportedMigrationError, sessionFormatCatalog } from "@x1a0f3n9/dsh-session-format-catalog";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { link, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, truncate } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, parse, resolve, toNamespacedPath } from "node:path";
|
|
6
|
+
import { performance } from "node:perf_hooks";
|
|
7
|
+
import { scheduler } from "node:timers/promises";
|
|
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 "@x1a0f3n9/dsh-session-persistence";
|
|
10
|
+
import { BlockAssembler, errorChain, expandAssistantStream } from "@x1a0f3n9/dsh-llm";
|
|
11
|
+
import { tryLockExclusive } from "@deepseek-ai/node-addon-system/flock";
|
|
12
|
+
import { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq, decodeSeqRanges, interruptedTurnClosers, isAppendSurfaceEvent } from "@x1a0f3n9/dsh-session";
|
|
13
|
+
import { SessionFormatUnsupportedMigrationError as SessionFormatUnsupportedMigrationError$1, parseSessionFormatLogFilename, sessionFormatLogFilename } from "@x1a0f3n9/dsh-session-format";
|
|
14
|
+
import { assertV3RowAdmission } from "@x1a0f3n9/dsh-session-format-v2-to-v3";
|
|
15
|
+
import { constants, createZstdCompress, createZstdDecompress, zstdCompress, zstdDecompress, zstdDecompressSync } from "node:zlib";
|
|
16
|
+
import { isDeepStrictEqual, promisify } from "node:util";
|
|
17
|
+
import { constants as constants$1 } from "node:buffer";
|
|
18
|
+
import { Worker } from "node:worker_threads";
|
|
19
|
+
import { Readable, pipeline } from "node:stream";
|
|
20
|
+
/**
|
|
21
|
+
* The JSONL session handle. Mutations serialize on a per-handle promise
|
|
22
|
+
* chain; reads re-scan the artifact on demand and never observe a shorter log
|
|
23
|
+
* than a prior read on this handle. Routed live events buffer in a bounded
|
|
24
|
+
* window and drain through the same chain as explicit appends.
|
|
25
|
+
*/
|
|
26
|
+
var JsonlSessionHandle = class {
|
|
27
|
+
storage;
|
|
28
|
+
id;
|
|
29
|
+
header;
|
|
30
|
+
access;
|
|
31
|
+
state;
|
|
32
|
+
lease;
|
|
33
|
+
chain = Promise.resolve();
|
|
34
|
+
closing;
|
|
35
|
+
observedLength = 0;
|
|
36
|
+
/** Routed live events awaiting their batching deadline (persistence-owned copies). */
|
|
37
|
+
buffered = [];
|
|
38
|
+
batchTimer;
|
|
39
|
+
/** Set when a drain failed; the automatic timer stays quiet until the next drain. */
|
|
40
|
+
drainPaused = false;
|
|
41
|
+
draining;
|
|
42
|
+
constructor(storage, id, header, access, state, lease) {
|
|
43
|
+
this.storage = storage;
|
|
44
|
+
this.id = id;
|
|
45
|
+
this.header = header;
|
|
46
|
+
this.access = access;
|
|
47
|
+
this.state = state;
|
|
48
|
+
this.lease = lease;
|
|
49
|
+
}
|
|
50
|
+
/** Exact fork-inherited prefix length stored with this session's log. */
|
|
51
|
+
get inheritedEventCount() {
|
|
52
|
+
return this.state.inheritedEventCount;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read a slice of the valid contiguous logical log; see the seam contract.
|
|
56
|
+
* @param offset - first logical seq to include (default 0).
|
|
57
|
+
* @param length - maximum events returned (default: the rest).
|
|
58
|
+
* @param options - optional cancellation.
|
|
59
|
+
* @returns a slice carrying the aliasing state established by its producer.
|
|
60
|
+
*/
|
|
61
|
+
async read(offset = 0, length = Number.MAX_SAFE_INTEGER, options) {
|
|
62
|
+
this.assertOpen("read");
|
|
63
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new TypeError(`read offset must be a non-negative safe integer, got ${String(offset)}`);
|
|
64
|
+
if (!Number.isSafeInteger(length) || length < 0) throw new TypeError(`read length must be a non-negative safe integer, got ${String(length)}`);
|
|
65
|
+
options?.signal?.throwIfAborted();
|
|
66
|
+
let result;
|
|
67
|
+
const primed = this.state.primed;
|
|
68
|
+
if (primed !== void 0) if (this.access === "write") result = this.readPrimed(primed, offset, length);
|
|
69
|
+
else {
|
|
70
|
+
const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal);
|
|
71
|
+
if (currentPath === void 0) result = this.readPrimed(primed, offset, length);
|
|
72
|
+
else {
|
|
73
|
+
this.state.primed = void 0;
|
|
74
|
+
result = await this.readCurrent(currentPath, offset, length, options?.signal);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (this.access === "write" && !this.state.materialized) result = {
|
|
78
|
+
eventState: "detached",
|
|
79
|
+
events: []
|
|
80
|
+
};
|
|
81
|
+
else {
|
|
82
|
+
const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal);
|
|
83
|
+
if (currentPath !== void 0) result = await this.readCurrent(currentPath, offset, length, options?.signal);
|
|
84
|
+
else if (this.storage.hasPendingSession(this.id)) result = {
|
|
85
|
+
eventState: "detached",
|
|
86
|
+
events: []
|
|
87
|
+
};
|
|
88
|
+
else throw new SessionPersistenceNotFoundError(this.id);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/** Read one slice from the prepared historical prefix retained by this handle. */
|
|
93
|
+
readPrimed(source, offset, length) {
|
|
94
|
+
this.observedLength = Math.max(this.observedLength, source.events.length);
|
|
95
|
+
return {
|
|
96
|
+
eventState: source.eventState,
|
|
97
|
+
events: source.events.slice(offset, offset + length)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Read one current physical generation and enforce this handle's monotonic view. */
|
|
101
|
+
async readCurrent(path, offset, length, signal) {
|
|
102
|
+
const source = await this.storage.readStoredLog(path, this.id, signal);
|
|
103
|
+
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})`);
|
|
104
|
+
this.observedLength = source.events.length;
|
|
105
|
+
return {
|
|
106
|
+
eventState: source.eventState,
|
|
107
|
+
events: source.events.slice(offset, offset + length)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Durably append a contiguous batch; see the seam contract.
|
|
112
|
+
* @param events - the contiguous batch in seq order.
|
|
113
|
+
* @param options - optional cancellation observed before the write starts.
|
|
114
|
+
*/
|
|
115
|
+
async append(events, options) {
|
|
116
|
+
this.assertOpen("append");
|
|
117
|
+
const batch = materializeAppendBatch(events);
|
|
118
|
+
return this.run("append", async () => {
|
|
119
|
+
options?.signal?.throwIfAborted();
|
|
120
|
+
await this.persistContiguous(batch);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Replace the current generation with an earlier contiguous prefix.
|
|
125
|
+
* @param length - number of events to retain.
|
|
126
|
+
* @returns resolution after the rewritten prefix is durable.
|
|
127
|
+
*/
|
|
128
|
+
async truncate(length) {
|
|
129
|
+
return this.run("truncate", async () => {
|
|
130
|
+
if (this.access !== "write") throw new SessionReadOnlyError(this.id, "truncate");
|
|
131
|
+
if (!Number.isSafeInteger(length) || length < 0) throw new TypeError(`truncate length must be a non-negative safe integer, got ${String(length)}`);
|
|
132
|
+
if (length < this.state.inheritedEventCount || length > this.state.cursor) throw new RangeError(`session truncation length ${String(length)} is outside the writable log prefix`);
|
|
133
|
+
if (length === this.state.cursor) return;
|
|
134
|
+
let source;
|
|
135
|
+
if (this.state.primed !== void 0) source = this.state.primed;
|
|
136
|
+
else if (!this.state.materialized) source = {
|
|
137
|
+
eventState: "detached",
|
|
138
|
+
events: []
|
|
139
|
+
};
|
|
140
|
+
else {
|
|
141
|
+
const path = await this.storage.resolveCurrentLog(this.id);
|
|
142
|
+
if (path === void 0) throw new SessionPersistenceNotFoundError(this.id);
|
|
143
|
+
source = await this.storage.readStoredLog(path, this.id);
|
|
144
|
+
}
|
|
145
|
+
if (source.events.length !== this.state.cursor) throw new Error(`session "${this.id}": stored log length changed while destructive deletion was preparing`);
|
|
146
|
+
await this.storage.rewrite(this.header, this.state.inheritedEventCount, source.events.slice(0, length));
|
|
147
|
+
this.state.cursor = length;
|
|
148
|
+
this.state.materialized = true;
|
|
149
|
+
this.state.primed = {
|
|
150
|
+
eventState: source.eventState,
|
|
151
|
+
events: source.events.slice(0, length)
|
|
152
|
+
};
|
|
153
|
+
this.state.tornTruncateTo = void 0;
|
|
154
|
+
this.state.recoveredTail = void 0;
|
|
155
|
+
this.observedLength = length;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Durability barrier; materializes the artifact when nothing has been
|
|
160
|
+
* appended yet, so an explicitly flushed empty session survives this process.
|
|
161
|
+
* @param options - optional cancellation observed before the barrier starts.
|
|
162
|
+
*/
|
|
163
|
+
flush(options) {
|
|
164
|
+
return this.run("flush", async () => {
|
|
165
|
+
options?.signal?.throwIfAborted();
|
|
166
|
+
if (this.access !== "write") throw new SessionReadOnlyError(this.id, "flush");
|
|
167
|
+
if (this.state.materialized) return;
|
|
168
|
+
await this.ensureLease();
|
|
169
|
+
await this.storage.persistHeader(this.header, this.state.inheritedEventCount);
|
|
170
|
+
this.state.materialized = true;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Release the handle; see the seam contract. Idempotent and uncancellable.
|
|
175
|
+
* A write handle first drains its routed live buffer through the still-open
|
|
176
|
+
* storage, so backend teardown loses nothing regardless of which fiber
|
|
177
|
+
* unwinds first; a drain or lock-release failure still frees the in-process
|
|
178
|
+
* claim, then rejects — both failures together reject as one
|
|
179
|
+
* `AggregateError`.
|
|
180
|
+
* @returns settlement of the release.
|
|
181
|
+
*/
|
|
182
|
+
close() {
|
|
183
|
+
return this.closing ??= (async () => {
|
|
184
|
+
let drainFailure;
|
|
185
|
+
for (;;) {
|
|
186
|
+
try {
|
|
187
|
+
await this.drainLive();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
drainFailure = error;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
await this.chain;
|
|
193
|
+
if (this.buffered.length === 0) break;
|
|
194
|
+
}
|
|
195
|
+
await this.chain;
|
|
196
|
+
const failures = [];
|
|
197
|
+
if (drainFailure !== void 0) failures.push(drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure)));
|
|
198
|
+
try {
|
|
199
|
+
await this.lease?.release();
|
|
200
|
+
} catch (releaseFailure) {
|
|
201
|
+
/* v8 ignore next -- lock releases reject with Error */
|
|
202
|
+
failures.push(releaseFailure instanceof Error ? releaseFailure : new Error(errorChain(releaseFailure)));
|
|
203
|
+
}
|
|
204
|
+
this.storage.releaseHandle(this, this.state.materialized);
|
|
205
|
+
if (failures.length > 1) throw new AggregateError(failures, `session "${this.id}": close failed to drain and to release its write lock`);
|
|
206
|
+
if (failures[0] !== void 0) throw failures[0];
|
|
207
|
+
})();
|
|
208
|
+
}
|
|
209
|
+
/** `await using` support: delegates to {@link close}. */
|
|
210
|
+
[Symbol.asyncDispose]() {
|
|
211
|
+
return this.close();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Buffer one published live session event and arm the bounded batching
|
|
215
|
+
* window when it is idle. The routing installer is the only caller.
|
|
216
|
+
* @param event - the live event, retained as a persistence-owned copy.
|
|
217
|
+
* @param reportBackgroundFailure - observes a deadline-driven drain failure
|
|
218
|
+
* (the events stay buffered; the next {@link drainLive} retries loudly).
|
|
219
|
+
*/
|
|
220
|
+
enqueueLive(event, reportBackgroundFailure) {
|
|
221
|
+
this.buffered.push(structuredClone(event));
|
|
222
|
+
if (this.batchTimer !== void 0 || this.drainPaused) return;
|
|
223
|
+
this.batchTimer = setTimeout(() => {
|
|
224
|
+
this.batchTimer = void 0;
|
|
225
|
+
this.drainLive().catch(reportBackgroundFailure);
|
|
226
|
+
}, 200);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Durably drain the routed live buffer through the mutation chain;
|
|
230
|
+
* concurrent callers join one drain, and a failure retains the batch in
|
|
231
|
+
* order so `session/flush` can retry and reject loudly.
|
|
232
|
+
*/
|
|
233
|
+
drainLive() {
|
|
234
|
+
return this.draining ??= this.drainBuffered().finally(() => {
|
|
235
|
+
this.draining = void 0;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async drainBuffered() {
|
|
239
|
+
if (this.batchTimer !== void 0) {
|
|
240
|
+
clearTimeout(this.batchTimer);
|
|
241
|
+
this.batchTimer = void 0;
|
|
242
|
+
}
|
|
243
|
+
this.drainPaused = false;
|
|
244
|
+
while (this.buffered.length > 0) await this.enqueueChain(async () => {
|
|
245
|
+
const batch = this.buffered.splice(0);
|
|
246
|
+
try {
|
|
247
|
+
await this.persistContiguous(materializeAppendBatch(batch));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.buffered = batch.concat(this.buffered);
|
|
250
|
+
this.drainPaused = true;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/** The shared durable-append body: contiguity, ownership, torn-tail repair, storage write, state advance. */
|
|
256
|
+
async persistContiguous(batch) {
|
|
257
|
+
if (this.access !== "write") throw new SessionReadOnlyError(this.id, "append");
|
|
258
|
+
if (batch.length === 0) return;
|
|
259
|
+
await this.ensureLease();
|
|
260
|
+
assertContiguous(this.id, batch, this.state.cursor);
|
|
261
|
+
if (this.state.tornTruncateTo !== void 0) {
|
|
262
|
+
await this.storage.truncateTornTail(this.header, this.state.tornTruncateTo);
|
|
263
|
+
this.state.tornTruncateTo = void 0;
|
|
264
|
+
}
|
|
265
|
+
if (this.state.recoveredTail !== void 0) {
|
|
266
|
+
if (this.state.recoveredTail.length > 0) await this.storage.persistBatch(this.header, this.state.recoveredTail, this.state.materialized, this.state.inheritedEventCount);
|
|
267
|
+
this.state.recoveredTail = void 0;
|
|
268
|
+
}
|
|
269
|
+
await this.storage.persistBatch(this.header, batch, this.state.materialized, this.state.inheritedEventCount);
|
|
270
|
+
this.state.materialized = true;
|
|
271
|
+
this.state.cursor += batch.length;
|
|
272
|
+
this.state.primed = void 0;
|
|
273
|
+
this.observedLength = this.state.cursor;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Hold the cross-process write lock before this session's first durable
|
|
277
|
+
* write. An open write handle holds it from construction; a create handle
|
|
278
|
+
* acquires it here — immediately before the first log bytes publish — and
|
|
279
|
+
* keeps it through close even when materialization then fails, so a
|
|
280
|
+
* materializing session stays exclusively owned across retries.
|
|
281
|
+
*/
|
|
282
|
+
async ensureLease() {
|
|
283
|
+
this.lease ??= await this.storage.acquireWriteLease(this.header);
|
|
284
|
+
}
|
|
285
|
+
/** Serialize one operation onto the chain without the closed-handle refusal (drain-from-close). */
|
|
286
|
+
enqueueChain(op) {
|
|
287
|
+
const next = this.chain.then(op);
|
|
288
|
+
this.chain = next.catch(() => {});
|
|
289
|
+
return next;
|
|
290
|
+
}
|
|
291
|
+
/** Serialize one public mutating operation onto this handle's chain. */
|
|
292
|
+
async run(operation, op) {
|
|
293
|
+
this.assertOpen(operation);
|
|
294
|
+
return this.enqueueChain(async () => {
|
|
295
|
+
this.assertOpen(operation);
|
|
296
|
+
return op();
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
assertOpen(operation) {
|
|
300
|
+
if (this.closing !== void 0) throw new SessionHandleClosedError(this.id, operation);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* The JSONL backend's in-process bookkeeping: the single active writer per
|
|
305
|
+
* session id (doubling as the live event router), the open-handle set the
|
|
306
|
+
* teardown sweep closes, and the created-but-unmaterialized sessions this
|
|
307
|
+
* process can already observe.
|
|
308
|
+
*/
|
|
309
|
+
var JsonlBackendTracker = class {
|
|
310
|
+
name;
|
|
311
|
+
/** Every open handle; teardown closes what remains. */
|
|
312
|
+
openHandles = /* @__PURE__ */ new Set();
|
|
313
|
+
/** `null` marks a claim whose handle is still being constructed. */
|
|
314
|
+
writers = /* @__PURE__ */ new Map();
|
|
315
|
+
pending = /* @__PURE__ */ new Map();
|
|
316
|
+
counter = 0;
|
|
317
|
+
/** @param name - backend label used in in-memory revision tokens and teardown errors. */
|
|
318
|
+
constructor(name) {
|
|
319
|
+
this.name = name;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Claim write ownership and record the created session as pending, making
|
|
323
|
+
* it observable to this process before it materializes. Before
|
|
324
|
+
* materialization this registration is the only guard — session ids do not
|
|
325
|
+
* collide across processes, and no durable artifact exists for another
|
|
326
|
+
* process to open; the handle takes the cross-process lock at its first
|
|
327
|
+
* materializing write.
|
|
328
|
+
* @param header - the validated detached header.
|
|
329
|
+
* @param inheritedEventCount - the exact fork-inherited prefix length.
|
|
330
|
+
* @throws {SessionAlreadyExistsError} when a concurrent create or an open
|
|
331
|
+
* write handle holds the id — for create, the duplicate is the fact.
|
|
332
|
+
*/
|
|
333
|
+
registerCreated(header, inheritedEventCount) {
|
|
334
|
+
if (this.writers.has(header.id)) throw new SessionAlreadyExistsError(header.id);
|
|
335
|
+
this.writers.set(header.id, null);
|
|
336
|
+
this.pending.set(header.id, {
|
|
337
|
+
header,
|
|
338
|
+
revision: SessionPersistenceRevision(`memory:${this.name}:${++this.counter}`),
|
|
339
|
+
inheritedEventCount
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Claim write ownership for an existing session.
|
|
344
|
+
* @param id - the session to claim.
|
|
345
|
+
* @throws {SessionAlreadyOwnedError} when an active write handle exists.
|
|
346
|
+
*/
|
|
347
|
+
claimWrite(id) {
|
|
348
|
+
if (this.writers.has(id)) throw new SessionAlreadyOwnedError(id);
|
|
349
|
+
this.writers.set(id, null);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Roll a failed write open back.
|
|
353
|
+
* @param id - the session whose claim is dropped.
|
|
354
|
+
*/
|
|
355
|
+
releaseClaim(id) {
|
|
356
|
+
this.writers.delete(id);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The pending entry for a created-but-unmaterialized session, if any.
|
|
360
|
+
* @param id - the session to look up.
|
|
361
|
+
* @returns the pending header and in-memory revision.
|
|
362
|
+
*/
|
|
363
|
+
pendingOf(id) {
|
|
364
|
+
return this.pending.get(id);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Whether this process still tracks a created-but-unmaterialized session.
|
|
368
|
+
* @param id - the session to test.
|
|
369
|
+
* @returns true while the pending entry exists.
|
|
370
|
+
*/
|
|
371
|
+
hasPending(id) {
|
|
372
|
+
return this.pending.has(id);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Return the active writer for one session, if this process owns it.
|
|
376
|
+
* @param id - the session to resolve.
|
|
377
|
+
* @returns the active writer, or undefined while no writer is open.
|
|
378
|
+
*/
|
|
379
|
+
writerOf(id) {
|
|
380
|
+
const writer = this.writers.get(id);
|
|
381
|
+
return writer === null ? void 0 : writer;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Iterate the pending sessions for listing.
|
|
385
|
+
* @returns the pending entries, keyed by session id.
|
|
386
|
+
*/
|
|
387
|
+
pendingEntries() {
|
|
388
|
+
return this.pending.entries();
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Drop a pending entry once the session materialized durably.
|
|
392
|
+
* @param id - the session that reached durable storage.
|
|
393
|
+
*/
|
|
394
|
+
materialized(id) {
|
|
395
|
+
this.pending.delete(id);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Track one open handle for teardown and, for a write handle, bind it as
|
|
399
|
+
* the session's live event route.
|
|
400
|
+
* @param handle - the just-constructed handle.
|
|
401
|
+
* @returns the same handle, for construction-site chaining.
|
|
402
|
+
*/
|
|
403
|
+
adopt(handle) {
|
|
404
|
+
this.openHandles.add(handle);
|
|
405
|
+
if (handle.access === "write") this.writers.set(handle.id, handle);
|
|
406
|
+
return handle;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Release one handle's bookkeeping on close. A write handle drops its
|
|
410
|
+
* ownership claim; a creator that never materialized leaves nothing behind —
|
|
411
|
+
* the session never existed.
|
|
412
|
+
* @param handle - the closing handle.
|
|
413
|
+
* @param materialized - whether the session reached durable storage.
|
|
414
|
+
*/
|
|
415
|
+
release(handle, materialized) {
|
|
416
|
+
this.openHandles.delete(handle);
|
|
417
|
+
if (handle.access !== "write") return;
|
|
418
|
+
this.writers.delete(handle.id);
|
|
419
|
+
if (!materialized) this.pending.delete(handle.id);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Drain and flush every active write handle — the service-wide durability
|
|
423
|
+
* barrier behind `SessionPersistence.flush`.
|
|
424
|
+
* @throws {AggregateError} naming each session whose flush failed; the
|
|
425
|
+
* remaining handles still flush.
|
|
426
|
+
*/
|
|
427
|
+
async flushAll() {
|
|
428
|
+
const errors = [];
|
|
429
|
+
for (const writer of [...this.writers.values()]) {
|
|
430
|
+
if (writer === null) continue;
|
|
431
|
+
try {
|
|
432
|
+
await writer.drainLive();
|
|
433
|
+
await writer.flush();
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (error instanceof SessionHandleClosedError) continue;
|
|
436
|
+
errors.push(error);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (errors.length > 0) throw new AggregateError(errors, `${this.name} flush failed`);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Install the backend's live session routing and teardown. Persistence
|
|
443
|
+
* enforces one active write handle per id, so the listeners route published
|
|
444
|
+
* sessions' events by id; the teardown effect closes every open handle —
|
|
445
|
+
* close drains the routed buffer — and aggregates failures. This provider
|
|
446
|
+
* owns no separate storage connection, so closing handles is the complete
|
|
447
|
+
* teardown. Registrations are effects of the current fiber.
|
|
448
|
+
* @param ctx - the backend's context.
|
|
449
|
+
*/
|
|
450
|
+
install(ctx) {
|
|
451
|
+
ctx.on("session/event", (session, event) => {
|
|
452
|
+
this.writers.get(session.id)?.enqueueLive(event, (error) => {
|
|
453
|
+
ctx.logger.warn(`session-persistence: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
ctx.on("session/flush", (session) => {
|
|
457
|
+
const writer = this.writers.get(session.id);
|
|
458
|
+
if (writer === null || writer === void 0) return void 0;
|
|
459
|
+
return (async () => {
|
|
460
|
+
await writer.drainLive();
|
|
461
|
+
await writer.flush();
|
|
462
|
+
})();
|
|
463
|
+
});
|
|
464
|
+
ctx.on("session/disposed", (session) => {
|
|
465
|
+
const writer = this.writers.get(session.id);
|
|
466
|
+
if (writer === null || writer === void 0) return;
|
|
467
|
+
writer.close().catch((error) => {
|
|
468
|
+
ctx.logger.warn(`session-persistence: final drain for session "${session.id}" failed: ${String(error)}`);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
ctx.effect(() => async () => {
|
|
472
|
+
const errors = [];
|
|
473
|
+
for (const handle of [...this.openHandles]) try {
|
|
474
|
+
await handle.close();
|
|
475
|
+
} catch (error) {
|
|
476
|
+
errors.push(error);
|
|
477
|
+
}
|
|
478
|
+
if (errors.length > 0) throw new AggregateError(errors, `${this.name} dispose failed`);
|
|
479
|
+
}, `${this.name} open handles`);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region lib/types/win32.js
|
|
484
|
+
/**
|
|
485
|
+
* Windows durable namespace helpers for the JSONL backend.
|
|
486
|
+
*
|
|
487
|
+
* POSIX publishes a newly-created log by creating a directory entry and then
|
|
488
|
+
* fsyncing the parent directory. Windows does not expose that parent-directory
|
|
489
|
+
* fsync contract through Node, so the Windows path uses the native durable
|
|
490
|
+
* namespace primitive instead: create a staging object in the target directory
|
|
491
|
+
* and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
|
|
492
|
+
* replacement or cross-volume copy fallback.
|
|
493
|
+
*
|
|
494
|
+
* @module dsh-session-persistence-jsonl/win32
|
|
495
|
+
*/
|
|
496
|
+
const MOVEFILE_WRITE_THROUGH = 8;
|
|
497
|
+
const WAIT_OBJECT_0 = 0;
|
|
498
|
+
const WAIT_TIMEOUT = 258;
|
|
499
|
+
const ERROR_FILE_NOT_FOUND = 2;
|
|
500
|
+
const ERROR_PATH_NOT_FOUND = 3;
|
|
501
|
+
const ERROR_ACCESS_DENIED = 5;
|
|
502
|
+
const ERROR_NOT_SAME_DEVICE = 17;
|
|
503
|
+
const ERROR_SHARING_VIOLATION = 32;
|
|
504
|
+
const ERROR_FILE_EXISTS = 80;
|
|
505
|
+
const ERROR_INVALID_NAME = 123;
|
|
506
|
+
const ERROR_ALREADY_EXISTS = 183;
|
|
507
|
+
let bindings;
|
|
508
|
+
/** Load the small Win32 API lazily so non-Windows processes never load Koffi. */
|
|
509
|
+
async function win32() {
|
|
510
|
+
if (bindings !== void 0) return bindings;
|
|
511
|
+
const kernel32 = (await import("koffi")).default.load("kernel32.dll");
|
|
512
|
+
bindings = {
|
|
513
|
+
moveFileExW: kernel32.func("__stdcall", "MoveFileExW", "int", [
|
|
514
|
+
"str16",
|
|
515
|
+
"str16",
|
|
516
|
+
"uint"
|
|
517
|
+
]),
|
|
518
|
+
createSemaphoreW: kernel32.func("__stdcall", "CreateSemaphoreW", "intptr", [
|
|
519
|
+
"void*",
|
|
520
|
+
"int",
|
|
521
|
+
"int",
|
|
522
|
+
"str16"
|
|
523
|
+
]),
|
|
524
|
+
waitForSingleObject: kernel32.func("__stdcall", "WaitForSingleObject", "uint", ["intptr", "uint"]),
|
|
525
|
+
releaseSemaphore: kernel32.func("__stdcall", "ReleaseSemaphore", "int", [
|
|
526
|
+
"intptr",
|
|
527
|
+
"int",
|
|
528
|
+
"void*"
|
|
529
|
+
]),
|
|
530
|
+
closeHandle: kernel32.func("__stdcall", "CloseHandle", "int", ["intptr"]),
|
|
531
|
+
getLastError: kernel32.func("__stdcall", "GetLastError", "uint", [])
|
|
532
|
+
};
|
|
533
|
+
return bindings;
|
|
534
|
+
}
|
|
535
|
+
function errnoCode(win32Code) {
|
|
536
|
+
switch (win32Code) {
|
|
537
|
+
case ERROR_FILE_NOT_FOUND:
|
|
538
|
+
case ERROR_PATH_NOT_FOUND: return "ENOENT";
|
|
539
|
+
case ERROR_ACCESS_DENIED: return "EACCES";
|
|
540
|
+
case ERROR_NOT_SAME_DEVICE: return "EXDEV";
|
|
541
|
+
case ERROR_SHARING_VIOLATION: return "EBUSY";
|
|
542
|
+
case ERROR_FILE_EXISTS:
|
|
543
|
+
case ERROR_ALREADY_EXISTS: return "EEXIST";
|
|
544
|
+
case ERROR_INVALID_NAME: return "EINVAL";
|
|
545
|
+
default: return "EIO";
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function win32Error(syscall, win32Code, path, dest) {
|
|
549
|
+
const code = errnoCode(win32Code);
|
|
550
|
+
const error = /* @__PURE__ */ new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`);
|
|
551
|
+
error.code = code;
|
|
552
|
+
error.errno = win32Code;
|
|
553
|
+
error.syscall = syscall;
|
|
554
|
+
error.path = path;
|
|
555
|
+
error.dest = dest;
|
|
556
|
+
error.win32Code = win32Code;
|
|
557
|
+
return error;
|
|
558
|
+
}
|
|
559
|
+
function isENOENT$1(error) {
|
|
560
|
+
return error?.code === "ENOENT";
|
|
561
|
+
}
|
|
562
|
+
function isEEXIST$1(error) {
|
|
563
|
+
return error?.code === "EEXIST";
|
|
564
|
+
}
|
|
565
|
+
async function assertDirectory(path) {
|
|
566
|
+
try {
|
|
567
|
+
if ((await stat(path === parse(path).root ? path : toNamespacedPath(path))).isDirectory()) return true;
|
|
568
|
+
const error = /* @__PURE__ */ new Error(`path exists but is not a directory: ${path}`);
|
|
569
|
+
error.code = "ENOTDIR";
|
|
570
|
+
error.path = path;
|
|
571
|
+
throw error;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
if (isENOENT$1(error)) return false;
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Publish `existing` at `replacement` with Windows write-through rename
|
|
579
|
+
* semantics. The destination must not already exist; the move must stay within
|
|
580
|
+
* the volume (no copy fallback flag is set).
|
|
581
|
+
* @param existing - the synced staging path to move.
|
|
582
|
+
* @param replacement - the final path, which must not already exist.
|
|
583
|
+
*/
|
|
584
|
+
async function publishNewFileWin32(existing, replacement) {
|
|
585
|
+
const api = await win32();
|
|
586
|
+
if (api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH) === 0) throw win32Error("MoveFileExW", api.getLastError(), existing, replacement);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Acquire the session write lock as a named kernel semaphore (count 1) whose
|
|
590
|
+
* name is derived from the canonical lock path. A kernel object never touches
|
|
591
|
+
* the filesystem, so readers, searches, and directory removal proceed freely
|
|
592
|
+
* while the lock is held; a second acquirer's zero-timeout wait times out
|
|
593
|
+
* (`EBUSY`); and when the last handle closes — including on any process
|
|
594
|
+
* death — the object is destroyed, so a successor's create starts fresh.
|
|
595
|
+
* @param path - the lock file path the name is derived from (case-folded:
|
|
596
|
+
* Windows paths are case-insensitive).
|
|
597
|
+
* @returns the open semaphore handle, released via {@link releaseLockHandleWin32}.
|
|
598
|
+
*/
|
|
599
|
+
async function acquireLockHandleWin32(path) {
|
|
600
|
+
const api = await win32();
|
|
601
|
+
const name = `Local\\dsh-session-lock-${createHash("sha256").update(resolve(path).toLowerCase()).digest("hex")}`;
|
|
602
|
+
const handle = api.createSemaphoreW(null, 1, 1, name);
|
|
603
|
+
if (handle === 0) throw win32Error("CreateSemaphoreW", api.getLastError(), path, name);
|
|
604
|
+
const wait = api.waitForSingleObject(handle, 0);
|
|
605
|
+
if (wait === WAIT_OBJECT_0) return handle;
|
|
606
|
+
api.closeHandle(handle);
|
|
607
|
+
if (wait === WAIT_TIMEOUT) throw win32Error("WaitForSingleObject", ERROR_SHARING_VIOLATION, path, name);
|
|
608
|
+
throw win32Error("WaitForSingleObject", api.getLastError(), path, name);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Release a lock from {@link acquireLockHandleWin32}: restore the semaphore
|
|
612
|
+
* count and close the handle (the object dies with its last handle).
|
|
613
|
+
* @param handle - the open semaphore handle.
|
|
614
|
+
*/
|
|
615
|
+
async function releaseLockHandleWin32(handle) {
|
|
616
|
+
const api = await win32();
|
|
617
|
+
const released = api.releaseSemaphore(handle, 1, null);
|
|
618
|
+
const closed = api.closeHandle(handle);
|
|
619
|
+
if (released === 0 || closed === 0) throw win32Error("ReleaseSemaphore", api.getLastError(), `handle:${handle}`, `handle:${handle}`);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Create `target` and its missing ancestors with durable Windows namespace
|
|
623
|
+
* publication. Each missing directory is first created as a random staging
|
|
624
|
+
* sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
|
|
625
|
+
* with another creator are accepted only after verifying the winner is a
|
|
626
|
+
* directory.
|
|
627
|
+
* @param target - the absolute directory path to create durably when absent.
|
|
628
|
+
*/
|
|
629
|
+
async function ensureDurableDirectoryWin32(target) {
|
|
630
|
+
const absolute = resolve(target);
|
|
631
|
+
const root = parse(absolute).root;
|
|
632
|
+
await assertDirectory(root);
|
|
633
|
+
const segments = absolute.slice(root.length).split(/[\\/]+/).filter((part) => part.length > 0);
|
|
634
|
+
let current = root;
|
|
635
|
+
for (const segment of segments) {
|
|
636
|
+
const next = join(current, segment);
|
|
637
|
+
if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next);
|
|
638
|
+
current = next;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function createLeafDirectoryWin32(parent, target) {
|
|
642
|
+
const staging = await mkdtemp(toNamespacedPath(join(parent, ".dsh-mkdir-")));
|
|
643
|
+
try {
|
|
644
|
+
await publishNewFileWin32(staging, target);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
await rm(staging, {
|
|
647
|
+
recursive: true,
|
|
648
|
+
force: true
|
|
649
|
+
});
|
|
650
|
+
if (isEEXIST$1(error) && await assertDirectory(target)) return;
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region lib/types/lease.js
|
|
656
|
+
/**
|
|
657
|
+
* Cross-process write-ownership lock for one session's artifact directory,
|
|
658
|
+
* held for the whole life of a write handle. The arbiter is the kernel:
|
|
659
|
+
* POSIX takes a non-blocking `flock(2)` via native system support on `session.lock`
|
|
660
|
+
* beside the log, and Windows holds a named kernel semaphore derived from
|
|
661
|
+
* that path — never a file lock or handle, so readers, searches, and
|
|
662
|
+
* directory removal proceed freely while the lock is held. Contention maps
|
|
663
|
+
* to `SessionAlreadyOwnedError`; the kernel releases the lock when the
|
|
664
|
+
* holder's descriptor or last object handle closes, including on any process
|
|
665
|
+
* death, so a crashed holder never blocks a successor. A live but wedged
|
|
666
|
+
* holder keeps the lock until its process exits: there is deliberately no
|
|
667
|
+
* expiry that could expropriate a stalled writer whose resumed appends would
|
|
668
|
+
* tear the log.
|
|
669
|
+
* A POSIX lock names an inode, not a path, so after locking the holder
|
|
670
|
+
* verifies the locked inode is still the file at the lock path and retries
|
|
671
|
+
* otherwise: an unlinked-and-recreated lock file carries a fresh inode, and
|
|
672
|
+
* a lock on the orphaned one proves nothing. Removing a live session's lock
|
|
673
|
+
* file therefore forfeits exclusion on POSIX (nothing in the harness does
|
|
674
|
+
* so); Windows has no lock file at all. Readers never touch the lock.
|
|
675
|
+
* The lock is acquired at write-open of an existing artifact and, for a
|
|
676
|
+
* created session, only right before its first materializing write — an
|
|
677
|
+
* unmaterialized session has no filesystem footprint. Release never removes
|
|
678
|
+
* the POSIX lock file: every acquired lock belongs to a materialized or
|
|
679
|
+
* materializing session, and the surviving file keeps the stable inode later
|
|
680
|
+
* lockers verify against. The browser worker stubs the native flock entry to
|
|
681
|
+
* immediate success: it is single-process, so the in-process write claim
|
|
682
|
+
* already excludes every writer.
|
|
683
|
+
* @module @x1a0f3n9/dsh-session-persistence-jsonl/lease
|
|
684
|
+
*/
|
|
685
|
+
/** Base name of the kernel lock file inside a session's directory. */
|
|
686
|
+
const LEASE_FILENAME = "session.lock";
|
|
687
|
+
/** Whether a flock failure means another descriptor holds the lock. */
|
|
688
|
+
function isLockContention(error) {
|
|
689
|
+
const code = error?.code;
|
|
690
|
+
return code === "EAGAIN" || code === "EWOULDBLOCK";
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* One held write lock. Constructed only by {@link SessionWriteLease.acquire};
|
|
694
|
+
* `release` closes the descriptor or handle, which is what releases the lock.
|
|
695
|
+
*/
|
|
696
|
+
var SessionWriteLease = class SessionWriteLease {
|
|
697
|
+
held;
|
|
698
|
+
released = false;
|
|
699
|
+
constructor(held) {
|
|
700
|
+
this.held = held;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Acquire the session directory's kernel write lock.
|
|
704
|
+
* @param dir - the session's artifact directory (created if absent).
|
|
705
|
+
* @param id - the session the lock guards, for error identities.
|
|
706
|
+
* @returns the held lock.
|
|
707
|
+
* @throws {SessionAlreadyOwnedError} while another holder keeps the lock.
|
|
708
|
+
*/
|
|
709
|
+
static async acquire(dir, id) {
|
|
710
|
+
const path = join(dir, LEASE_FILENAME);
|
|
711
|
+
await mkdir(dir, {
|
|
712
|
+
recursive: true,
|
|
713
|
+
mode: 448
|
|
714
|
+
});
|
|
715
|
+
/* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */
|
|
716
|
+
if (process.platform === "win32") {
|
|
717
|
+
let handle;
|
|
718
|
+
try {
|
|
719
|
+
handle = await acquireLockHandleWin32(path);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (error?.code === "EBUSY") throw new SessionAlreadyOwnedError(id);
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
return new SessionWriteLease({
|
|
725
|
+
kind: "win32",
|
|
726
|
+
handle
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/* v8 ignore stop */
|
|
730
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
731
|
+
const handle = await open(path, "w");
|
|
732
|
+
try {
|
|
733
|
+
try {
|
|
734
|
+
await tryLockExclusive(handle.fd);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
if (isLockContention(error)) throw new SessionAlreadyOwnedError(id);
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
739
|
+
const held = await handle.stat({ bigint: true });
|
|
740
|
+
const current = await stat(path, { bigint: true }).catch((error) => {
|
|
741
|
+
if (error?.code === "ENOENT") return void 0;
|
|
742
|
+
throw error;
|
|
743
|
+
});
|
|
744
|
+
if (current !== void 0 && current.ino === held.ino && current.dev === held.dev) return new SessionWriteLease({
|
|
745
|
+
kind: "posix",
|
|
746
|
+
handle
|
|
747
|
+
});
|
|
748
|
+
} catch (error) {
|
|
749
|
+
await handle.close();
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
await handle.close();
|
|
753
|
+
}
|
|
754
|
+
throw new SessionAlreadyOwnedError(id);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Release the kernel lock by closing its descriptor or handle. The POSIX
|
|
758
|
+
* lock file is never removed: every acquired lock belongs to a
|
|
759
|
+
* materialized or materializing session, and keeping the file preserves
|
|
760
|
+
* the stable inode later lockers verify against. Idempotent.
|
|
761
|
+
*/
|
|
762
|
+
async release() {
|
|
763
|
+
if (this.released) return;
|
|
764
|
+
this.released = true;
|
|
765
|
+
/* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */
|
|
766
|
+
if (this.held.kind === "win32") {
|
|
767
|
+
await releaseLockHandleWin32(this.held.handle);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
/* v8 ignore stop */
|
|
771
|
+
await this.held.handle.close();
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region lib/types/format.js
|
|
776
|
+
/**
|
|
777
|
+
* On-disk format helpers for the JSONL session-persistence backend: path
|
|
778
|
+
* sanitization (a {@link SessionId} is an unvalidated branded string, so it
|
|
779
|
+
* MUST be encoded before use in a path — no traversal, no collision), the
|
|
780
|
+
* per-project/session directory layout, header-line (de)serialization, and the
|
|
781
|
+
* truncation-repair offset computation.
|
|
782
|
+
*
|
|
783
|
+
* @module dsh-session-persistence-jsonl/format
|
|
784
|
+
*/
|
|
785
|
+
/**
|
|
786
|
+
* Return the artifact suffix for one physical encoding.
|
|
787
|
+
* @param compression - configured JSONL artifact encoding.
|
|
788
|
+
* @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
|
|
789
|
+
*/
|
|
790
|
+
function logSuffix(compression) {
|
|
791
|
+
return `.jsonl${compressionSuffix(compression)}`;
|
|
792
|
+
}
|
|
793
|
+
function compressionSuffix(compression) {
|
|
794
|
+
return compression === "zstd" ? ".zstd" : "";
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Return the canonical filename for one immutable Session format generation.
|
|
798
|
+
* Version zero retains the original suffix-only name; every later generation
|
|
799
|
+
* carries a lowercase numeric `vN` component.
|
|
800
|
+
* @param version - non-negative safe Session format version.
|
|
801
|
+
* @param compression - configured JSONL artifact encoding.
|
|
802
|
+
* @returns the generation filename inside one Session directory.
|
|
803
|
+
*/
|
|
804
|
+
function generationLogFilename(version, compression) {
|
|
805
|
+
return `${sessionFormatLogFilename(version)}${compressionSuffix(compression)}`;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Parse one canonical generation filename for the selected physical encoding.
|
|
809
|
+
* Noncanonical, temporary, uppercase, leading-zero, and version-zero-tagged names do
|
|
810
|
+
* not identify committed generations.
|
|
811
|
+
* @param filename - one entry from a Session directory.
|
|
812
|
+
* @param compression - configured JSONL artifact encoding.
|
|
813
|
+
* @returns its format version, or `undefined` when the name is not canonical.
|
|
814
|
+
*/
|
|
815
|
+
function parseGenerationLogFilename(filename, compression) {
|
|
816
|
+
const suffix = compressionSuffix(compression);
|
|
817
|
+
if (!filename.endsWith(suffix)) return void 0;
|
|
818
|
+
return parseSessionFormatLogFilename(filename.slice(0, filename.length - suffix.length));
|
|
819
|
+
}
|
|
820
|
+
const HEADER_REQUIRED_KEYS = [
|
|
821
|
+
"type",
|
|
822
|
+
"version",
|
|
823
|
+
"id",
|
|
824
|
+
"createdAt",
|
|
825
|
+
"isSeeded",
|
|
826
|
+
"delegationDepth"
|
|
827
|
+
];
|
|
828
|
+
const HEADER_OPTIONAL_KEYS = [
|
|
829
|
+
"cwd",
|
|
830
|
+
"parentSession",
|
|
831
|
+
"origin",
|
|
832
|
+
"agentPreset"
|
|
833
|
+
];
|
|
834
|
+
const HEADER_KEYS = new Set([...HEADER_REQUIRED_KEYS, ...HEADER_OPTIONAL_KEYS]);
|
|
835
|
+
/**
|
|
836
|
+
* Refuse policy fields that never belong to a released Session header.
|
|
837
|
+
* @param value - parsed physical header candidate.
|
|
838
|
+
* @returns nothing after successful validation.
|
|
839
|
+
*/
|
|
840
|
+
function assertNoRetiredHeaderFields(value) {
|
|
841
|
+
if (typeof value !== "object" || value === null) return;
|
|
842
|
+
if (Object.hasOwn(value, "sandboxMode") || Object.hasOwn(value, "approvalPolicy")) throw new Error("session header uses retired policy baseline fields");
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Build the header line object from a {@link SessionHeader}.
|
|
846
|
+
* @param header - the immutable session metadata to serialize.
|
|
847
|
+
* @param inheritedEventCount - exact inherited prefix length; required for a
|
|
848
|
+
* seeded header and omitted only for an unseeded header.
|
|
849
|
+
* @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null).
|
|
850
|
+
*/
|
|
851
|
+
function toHeaderLine(header, inheritedEventCount) {
|
|
852
|
+
if (header.isSeeded && inheritedEventCount === void 0) throw new Error("seeded session header requires an inherited event count");
|
|
853
|
+
const cut = SessionLogOffset(inheritedEventCount ?? 0);
|
|
854
|
+
if (!header.isSeeded && cut !== 0) throw new Error("unseeded session header inherited event count must be 0");
|
|
855
|
+
return sessionFormatCatalog.encodeCurrentHeader({
|
|
856
|
+
...header,
|
|
857
|
+
delegationDepth: header.delegationDepth ?? 0
|
|
858
|
+
}, cut);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Translate one current physical header into logical metadata and its cut.
|
|
862
|
+
* @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
|
|
863
|
+
* @returns logical Session metadata paired with the exact inherited prefix length.
|
|
864
|
+
*/
|
|
865
|
+
function fromHeaderLine(line) {
|
|
866
|
+
return {
|
|
867
|
+
meta: {
|
|
868
|
+
version: SESSION_FORMAT_VERSION,
|
|
869
|
+
id: line.id,
|
|
870
|
+
createdAt: line.createdAt,
|
|
871
|
+
...line.cwd !== void 0 ? { cwd: line.cwd } : {},
|
|
872
|
+
...line.parentSession !== void 0 ? { parentSession: line.parentSession } : {},
|
|
873
|
+
isSeeded: line.isSeeded,
|
|
874
|
+
...line.origin !== void 0 ? { origin: line.origin } : {},
|
|
875
|
+
delegationDepth: line.delegationDepth,
|
|
876
|
+
...line.agentPreset !== void 0 ? { agentPreset: line.agentPreset } : {}
|
|
877
|
+
},
|
|
878
|
+
inheritedEventCount: SessionLogOffset(0)
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
/** Type guard: a parsed first line is a well-formed session header. */
|
|
882
|
+
function isHeaderLine(value) {
|
|
883
|
+
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");
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16)
|
|
887
|
+
* strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string,
|
|
888
|
+
* so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use.
|
|
889
|
+
* Safe code units remain literal; every other unit, including `~`, becomes
|
|
890
|
+
* `~XXXX`. Operating on code units preserves lone surrogates, while special-
|
|
891
|
+
* casing `.` and `..` prevents traversal by an otherwise safe whole segment.
|
|
892
|
+
*
|
|
893
|
+
* @param raw - the string to encode; must be non-empty (throws on `''`).
|
|
894
|
+
* @returns the escaped single path segment, decodable back to `raw`.
|
|
895
|
+
*/
|
|
896
|
+
function encodeSegment(raw) {
|
|
897
|
+
if (raw.length === 0) throw new Error("cannot encode an empty path segment");
|
|
898
|
+
if (raw === ".") return "~002E";
|
|
899
|
+
if (raw === "..") return "~002E~002E";
|
|
900
|
+
let out = "";
|
|
901
|
+
for (let i = 0; i < raw.length; i++) {
|
|
902
|
+
const code = raw.charCodeAt(i);
|
|
903
|
+
const ch = String.fromCharCode(code);
|
|
904
|
+
if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
|
|
905
|
+
else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
906
|
+
}
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Build the readable directory key for a project path.
|
|
911
|
+
* Filesystem separators and drive separators become `-`; unsafe code units use
|
|
912
|
+
* the same `~XXXX` escape as session ids. The key is bounded for filesystem
|
|
913
|
+
* component limits. Separator replacement and truncation are intentionally
|
|
914
|
+
* lossy, following the common human-navigable project-directory convention.
|
|
915
|
+
* @param cwd - the session's project directory.
|
|
916
|
+
* @returns a single filesystem-safe project directory name.
|
|
917
|
+
*/
|
|
918
|
+
function projectKey(cwd) {
|
|
919
|
+
if (cwd.length === 0) throw new Error("cannot encode an empty project path");
|
|
920
|
+
let readable = "";
|
|
921
|
+
let separatorRun = false;
|
|
922
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
923
|
+
const code = cwd.charCodeAt(i);
|
|
924
|
+
const ch = String.fromCharCode(code);
|
|
925
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
926
|
+
if (!separatorRun) readable += "-";
|
|
927
|
+
separatorRun = true;
|
|
928
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
929
|
+
readable += ch;
|
|
930
|
+
separatorRun = false;
|
|
931
|
+
} else {
|
|
932
|
+
readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
933
|
+
separatorRun = false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* The configured root's human-navigable project directory. A configured root
|
|
940
|
+
* may be local or shared; this grouping does not prescribe its deployment.
|
|
941
|
+
* @param root - the backend's session root directory.
|
|
942
|
+
* @param cwd - the session's project directory; `undefined` selects `_no-cwd`.
|
|
943
|
+
* @returns the project directory path under `root`.
|
|
944
|
+
*/
|
|
945
|
+
function projectDir(root, cwd) {
|
|
946
|
+
if (cwd === void 0) return join(root, "_no-cwd");
|
|
947
|
+
return join(root, projectKey(cwd));
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* The directory owned by one session and available for future session-local
|
|
951
|
+
* artifacts.
|
|
952
|
+
* @param root - the backend's session root directory.
|
|
953
|
+
* @param cwd - the session's project directory.
|
|
954
|
+
* @param id - the session id, encoded to one safe path segment.
|
|
955
|
+
* @returns the session directory beneath its project directory.
|
|
956
|
+
*/
|
|
957
|
+
function sessionDir(root, cwd, id) {
|
|
958
|
+
return join(projectDir(root, cwd), encodeSegment(id));
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Build one immutable Session format generation path.
|
|
962
|
+
* @param root - the backend's session root directory.
|
|
963
|
+
* @param cwd - the session's project directory (`undefined` → `_no-cwd`).
|
|
964
|
+
* @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
|
|
965
|
+
* @param version - physical Session format generation.
|
|
966
|
+
* @param compression - physical artifact encoding and filename suffix.
|
|
967
|
+
* @returns the selected generation's configured JSONL artifact path.
|
|
968
|
+
*/
|
|
969
|
+
function generationLogPath(root, cwd, id, version, compression) {
|
|
970
|
+
return join(sessionDir(root, cwd, id), generationLogFilename(version, compression));
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Build the current generation's append target path for a Session.
|
|
974
|
+
* @param root - the backend's session root directory.
|
|
975
|
+
* @param cwd - the session's project directory (`undefined` → `_no-cwd`).
|
|
976
|
+
* @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
|
|
977
|
+
* @param compression - physical artifact encoding and filename suffix.
|
|
978
|
+
* @returns the current Session format generation path.
|
|
979
|
+
*/
|
|
980
|
+
function logPath(root, cwd, id, compression) {
|
|
981
|
+
return generationLogPath(root, cwd, id, SESSION_FORMAT_VERSION, compression);
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Serialize a current event batch as JSONL lines (no trailing newline). Compact
|
|
985
|
+
* Assistant streams are nested event data; every event occupies one row.
|
|
986
|
+
* @param events - the batch to serialize, in log order.
|
|
987
|
+
* @returns the batch's JSONL text; the writer adds the final newline.
|
|
988
|
+
*/
|
|
989
|
+
function eventLines(events) {
|
|
990
|
+
return events.map(eventLine).join("\n");
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Serialize one current event as one JSONL record without its trailing newline.
|
|
994
|
+
* @param event - current event to encode.
|
|
995
|
+
* @returns one physical JSON record.
|
|
996
|
+
*/
|
|
997
|
+
function eventLine(event) {
|
|
998
|
+
return JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event));
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Refuse a header carrying a format version this build does not read BEFORE
|
|
1002
|
+
* validating the current header shape or decoding any event row: a future
|
|
1003
|
+
* format need not satisfy this build's structural checks at all, and its user
|
|
1004
|
+
* must see "upgrade the harness", never "corrupt session log".
|
|
1005
|
+
* @param parsed - the JSON-parsed first line of a session artifact.
|
|
1006
|
+
*/
|
|
1007
|
+
function refuseForeignFormatVersion(parsed) {
|
|
1008
|
+
const { version, id } = parsed;
|
|
1009
|
+
if (typeof version !== "number" || version === SESSION_FORMAT_VERSION) return;
|
|
1010
|
+
throw new SessionFormatUnsupportedError(sessionFormatVersionRefusal(typeof id === "string" ? id : String(id), version));
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Parse one complete header record supplied independently from event rows.
|
|
1014
|
+
* @param record - the complete first JSONL record, including its newline.
|
|
1015
|
+
* @returns logical metadata and a restore stream positioned after the header.
|
|
1016
|
+
*/
|
|
1017
|
+
function parseHeaderRecord(record) {
|
|
1018
|
+
if (record.length === 0 || record.at(-1) !== 10 || record.indexOf(10) !== record.length - 1) throw new Error("empty or header-less session log");
|
|
1019
|
+
let parsed;
|
|
1020
|
+
try {
|
|
1021
|
+
parsed = JSON.parse(record.subarray(0, -1).toString("utf8"));
|
|
1022
|
+
} catch {
|
|
1023
|
+
throw new Error("corrupt session log: header line is not valid JSON");
|
|
1024
|
+
}
|
|
1025
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("corrupt session log: first line is not a JSON object");
|
|
1026
|
+
refuseForeignFormatVersion(parsed);
|
|
1027
|
+
assertNoRetiredHeaderFields(parsed);
|
|
1028
|
+
if (!isHeaderLine(parsed)) throw new Error("corrupt session log: first line is not a session header");
|
|
1029
|
+
let restore;
|
|
1030
|
+
try {
|
|
1031
|
+
restore = sessionFormatCatalog.createRestore(parsed, {
|
|
1032
|
+
recovery: "strict",
|
|
1033
|
+
validation: "transformed"
|
|
1034
|
+
});
|
|
1035
|
+
} catch {
|
|
1036
|
+
/* v8 ignore next -- isHeaderLine matches the current codec; this preserves classification if it tightens. */
|
|
1037
|
+
throw new Error("corrupt session log: first line is not a session header");
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
meta: fromHeaderLine(parsed).meta,
|
|
1041
|
+
restore
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Incrementally scan complete JSONL event records after an independently
|
|
1046
|
+
* supplied header record. Newline search and byte offsets stay on raw buffers;
|
|
1047
|
+
* only complete records are decoded to UTF-8. A fragment crossing writes is
|
|
1048
|
+
* copied because a decoder may reuse its output buffer after `write()` returns.
|
|
1049
|
+
*/
|
|
1050
|
+
var SessionLogScanner = class {
|
|
1051
|
+
recovery;
|
|
1052
|
+
meta;
|
|
1053
|
+
restore;
|
|
1054
|
+
eventCount = 0;
|
|
1055
|
+
fragments = [];
|
|
1056
|
+
fragmentBytes = 0;
|
|
1057
|
+
inputBytes;
|
|
1058
|
+
committedBytes;
|
|
1059
|
+
eventLine = 0;
|
|
1060
|
+
issue;
|
|
1061
|
+
finished = false;
|
|
1062
|
+
/**
|
|
1063
|
+
* Create an event scanner from exactly one newline-terminated header record.
|
|
1064
|
+
* @param headerRecord - the complete first JSONL record, including its newline.
|
|
1065
|
+
*/
|
|
1066
|
+
constructor(headerRecord, recovery = "recoverable") {
|
|
1067
|
+
this.recovery = recovery;
|
|
1068
|
+
const parsed = parseHeaderRecord(headerRecord);
|
|
1069
|
+
this.meta = parsed.meta;
|
|
1070
|
+
this.restore = parsed.restore;
|
|
1071
|
+
this.inputBytes = headerRecord.length;
|
|
1072
|
+
this.committedBytes = headerRecord.length;
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Consume the next raw plaintext chunk, retaining only an incomplete final record.
|
|
1076
|
+
* @param chunk - bytes immediately following all previously supplied bytes.
|
|
1077
|
+
*/
|
|
1078
|
+
write(chunk) {
|
|
1079
|
+
if (this.finished) throw new Error("cannot write to a finished session log scanner");
|
|
1080
|
+
const chunkStart = this.inputBytes;
|
|
1081
|
+
this.inputBytes += chunk.length;
|
|
1082
|
+
let lineStart = 0;
|
|
1083
|
+
for (let newline = chunk.indexOf(10); newline !== -1; newline = chunk.indexOf(10, lineStart)) {
|
|
1084
|
+
const fragment = chunk.subarray(lineStart, newline);
|
|
1085
|
+
let line = fragment;
|
|
1086
|
+
if (this.fragments.length > 0) {
|
|
1087
|
+
if (fragment.length > 0) this.fragments.push(fragment);
|
|
1088
|
+
line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length);
|
|
1089
|
+
this.fragments = [];
|
|
1090
|
+
this.fragmentBytes = 0;
|
|
1091
|
+
}
|
|
1092
|
+
this.consumeEventLine(line, chunkStart + newline + 1);
|
|
1093
|
+
lineStart = newline + 1;
|
|
1094
|
+
}
|
|
1095
|
+
if (lineStart < chunk.length) {
|
|
1096
|
+
const fragment = Buffer.from(chunk.subarray(lineStart));
|
|
1097
|
+
this.fragments.push(fragment);
|
|
1098
|
+
this.fragmentBytes += fragment.length;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Snapshot progress before appending a recoverable torn-frame prefix.
|
|
1103
|
+
* @returns byte, committed-prefix, and expanded-event cursors.
|
|
1104
|
+
*/
|
|
1105
|
+
checkpoint() {
|
|
1106
|
+
return {
|
|
1107
|
+
inputBytes: this.inputBytes,
|
|
1108
|
+
committedBytes: this.committedBytes,
|
|
1109
|
+
eventCount: SessionLogOffset(this.eventCount)
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Finish scanning, ignoring a final record without a newline as a torn tail.
|
|
1114
|
+
* @returns the header, contiguous event prefix, and safe truncation offset.
|
|
1115
|
+
*/
|
|
1116
|
+
finish() {
|
|
1117
|
+
this.finished = true;
|
|
1118
|
+
const artifact = this.restore.finish();
|
|
1119
|
+
return {
|
|
1120
|
+
meta: this.meta,
|
|
1121
|
+
inheritedEventCount: SessionLogOffset(artifact.inheritedEventCount),
|
|
1122
|
+
events: artifact.events,
|
|
1123
|
+
committedBytes: this.committedBytes
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/** Decode one complete event row and update the contiguous prefix. */
|
|
1127
|
+
consumeEventLine(line, endByte) {
|
|
1128
|
+
this.eventLine += 1;
|
|
1129
|
+
let decoded;
|
|
1130
|
+
try {
|
|
1131
|
+
decoded = JSON.parse(line.toString("utf8"));
|
|
1132
|
+
} catch {
|
|
1133
|
+
const issue = /* @__PURE__ */ new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`);
|
|
1134
|
+
if (this.recovery === "strict") throw issue;
|
|
1135
|
+
this.issue ??= issue;
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
try {
|
|
1139
|
+
assertV3RowAdmission(decoded);
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
if (error instanceof SessionFormatUnsupportedMigrationError$1) throw new SessionFormatUnsupportedError(error.message);
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
if (this.issue !== void 0) {
|
|
1145
|
+
if (typeof decoded === "object" && decoded !== null && decoded.type === "turn/end") throw this.issue;
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
this.restore.decodeRow(decoded);
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
/* v8 ignore next -- every production Session format decoder rejects with Error. */
|
|
1152
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1153
|
+
const issue = new Error(`corrupt session log: invalid committed event at line ${this.eventLine}: ${detail}`, { cause: error });
|
|
1154
|
+
if (this.recovery === "strict") throw issue;
|
|
1155
|
+
this.issue = issue;
|
|
1156
|
+
if (typeof decoded === "object" && decoded !== null && decoded.type === "turn/end") throw issue;
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
this.eventCount += 1;
|
|
1160
|
+
this.committedBytes = endByte;
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
/**
|
|
1164
|
+
* Parse a complete or torn JSONL buffer into its preserved event prefix. This
|
|
1165
|
+
* compatibility wrapper supplies the first record separately, then delegates
|
|
1166
|
+
* event rows to {@link SessionLogScanner}.
|
|
1167
|
+
*
|
|
1168
|
+
* @param buffer - the raw bytes of the log file (header line first).
|
|
1169
|
+
* @returns the header, preserved event prefix, and byte offset safe to append at.
|
|
1170
|
+
*/
|
|
1171
|
+
function scanLog(buffer) {
|
|
1172
|
+
const headerEnd = buffer.indexOf(10);
|
|
1173
|
+
if (headerEnd === -1) throw new Error("empty or header-less session log");
|
|
1174
|
+
const scanner = new SessionLogScanner(buffer.subarray(0, headerEnd + 1));
|
|
1175
|
+
scanner.write(buffer.subarray(headerEnd + 1));
|
|
1176
|
+
return scanner.finish();
|
|
1177
|
+
}
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region lib/types/zstd-private-decoder.js
|
|
1180
|
+
/**
|
|
1181
|
+
* Node-private synchronous Zstandard frame decoder optimization.
|
|
1182
|
+
* @module dsh-session-persistence-jsonl/zstd-private-decoder
|
|
1183
|
+
*/
|
|
1184
|
+
const DECODE_CHUNK_SIZE = 1024 * 1024;
|
|
1185
|
+
/** Return the stream with its observed private Node contract, or reject that optimization. */
|
|
1186
|
+
function privateZstdStream(stream) {
|
|
1187
|
+
const candidate = stream;
|
|
1188
|
+
const handle = candidate._handle;
|
|
1189
|
+
const errorKey = Reflect.ownKeys(stream).find((key) => typeof key === "symbol" && key.description === "kError");
|
|
1190
|
+
/* v8 ignore next -- one test runtime exposes one Node-private shape; the Node 22/24/26 matrix checks compatibility. */
|
|
1191
|
+
if (typeof handle !== "object" || handle === null || typeof handle.writeSync !== "function" || !(candidate._writeState instanceof Uint32Array) || candidate._writeState.length < 2 || typeof candidate._defaultFlushFlag !== "number" || errorKey === void 0 || candidate[errorKey] !== null) return void 0;
|
|
1192
|
+
return {
|
|
1193
|
+
stream,
|
|
1194
|
+
errorKey
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Synchronous multi-frame decoder backed by one Node Zstd stream handle. Node
|
|
1199
|
+
* exposes synchronous decoding only as a one-shot API, so this adapter uses
|
|
1200
|
+
* the stream's private handle contract to reuse its native context and output
|
|
1201
|
+
* chunks across frames.
|
|
1202
|
+
*/
|
|
1203
|
+
var NodePrivateZstdFrameDecoder = class NodePrivateZstdFrameDecoder {
|
|
1204
|
+
stream;
|
|
1205
|
+
errorKey;
|
|
1206
|
+
output = Buffer.allocUnsafe(DECODE_CHUNK_SIZE);
|
|
1207
|
+
decoderError;
|
|
1208
|
+
started = false;
|
|
1209
|
+
closed = false;
|
|
1210
|
+
constructor(stream, errorKey) {
|
|
1211
|
+
this.stream = stream;
|
|
1212
|
+
this.errorKey = errorKey;
|
|
1213
|
+
this.stream.on("error", (error) => {
|
|
1214
|
+
this.decoderError ??= error;
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Create the optimized decoder when this Node release exposes the expected
|
|
1219
|
+
* private stream shape.
|
|
1220
|
+
* @returns a shared decoder, or `undefined` when callers must use the public fallback.
|
|
1221
|
+
*/
|
|
1222
|
+
static create() {
|
|
1223
|
+
const stream = createZstdDecompress({ chunkSize: DECODE_CHUNK_SIZE });
|
|
1224
|
+
const privateAccess = privateZstdStream(stream);
|
|
1225
|
+
/* v8 ignore next -- reached only when a supported Node release changes its private stream shape. */
|
|
1226
|
+
if (privateAccess !== void 0) return new NodePrivateZstdFrameDecoder(privateAccess.stream, privateAccess.errorKey);
|
|
1227
|
+
/* v8 ignore next -- the active Node runtime passed the private-shape probe above. */
|
|
1228
|
+
stream.close();
|
|
1229
|
+
}
|
|
1230
|
+
/** @inheritdoc */
|
|
1231
|
+
*decode(source, frames) {
|
|
1232
|
+
if (this.started) throw new Error("Zstandard frame decoder was already started");
|
|
1233
|
+
if (this.closed) throw new Error("cannot start a closed Zstandard frame decoder");
|
|
1234
|
+
this.started = true;
|
|
1235
|
+
try {
|
|
1236
|
+
for (const frame of frames) try {
|
|
1237
|
+
yield this.decodeFrame(source.subarray(frame.start, frame.end));
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error });
|
|
1240
|
+
}
|
|
1241
|
+
} finally {
|
|
1242
|
+
this.close();
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/** Decode one frame; its returned scratch view remains valid until the next call. */
|
|
1246
|
+
decodeFrame(input) {
|
|
1247
|
+
const handle = this.stream._handle;
|
|
1248
|
+
/* v8 ignore next -- decode() rejects closed instances before entering this private frame operation. */
|
|
1249
|
+
if (this.closed || handle === null) throw new Error("cannot decode with a closed Zstandard frame decoder");
|
|
1250
|
+
let inputOffset = 0;
|
|
1251
|
+
let inputRemaining = input.length;
|
|
1252
|
+
let outputBytes = 0;
|
|
1253
|
+
const fullChunks = [];
|
|
1254
|
+
for (;;) {
|
|
1255
|
+
handle.writeSync(this.stream._defaultFlushFlag, input, inputOffset, inputRemaining, this.output, 0, this.output.length);
|
|
1256
|
+
if (this.decoderError !== void 0) throw this.decoderError;
|
|
1257
|
+
const internalError = this.stream[this.errorKey];
|
|
1258
|
+
if (internalError !== null) {
|
|
1259
|
+
if (internalError instanceof Error) throw internalError;
|
|
1260
|
+
throw new Error("Zstandard decoder exposed a non-Error internal failure");
|
|
1261
|
+
}
|
|
1262
|
+
const outputAfter = this.stream._writeState[0];
|
|
1263
|
+
const inputAfter = this.stream._writeState[1];
|
|
1264
|
+
const consumed = inputRemaining - inputAfter;
|
|
1265
|
+
const produced = this.output.length - outputAfter;
|
|
1266
|
+
if (produced > 0) {
|
|
1267
|
+
outputBytes += produced;
|
|
1268
|
+
/* v8 ignore next -- Buffer cannot materialize a frame beyond its own process-wide maximum length. */
|
|
1269
|
+
if (outputBytes > constants$1.MAX_LENGTH) throw new Error(`Zstandard frame output exceeds ${constants$1.MAX_LENGTH} bytes`);
|
|
1270
|
+
}
|
|
1271
|
+
if (outputAfter !== 0) {
|
|
1272
|
+
/* v8 ignore next -- structurally scanned ranges contain exactly one complete frame and no trailing bytes. */
|
|
1273
|
+
if (inputAfter !== 0) throw new Error("Zstandard frame decoder left trailing input");
|
|
1274
|
+
const finalChunk = this.output.subarray(0, produced);
|
|
1275
|
+
if (fullChunks.length === 0) return finalChunk;
|
|
1276
|
+
if (produced > 0) fullChunks.push(Buffer.from(finalChunk));
|
|
1277
|
+
const onlyChunk = fullChunks[0];
|
|
1278
|
+
return fullChunks.length === 1 ? onlyChunk : Buffer.concat(fullChunks, outputBytes);
|
|
1279
|
+
}
|
|
1280
|
+
fullChunks.push(Buffer.from(this.output));
|
|
1281
|
+
inputOffset += consumed;
|
|
1282
|
+
inputRemaining = inputAfter;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/** @inheritdoc */
|
|
1286
|
+
close() {
|
|
1287
|
+
if (this.closed) return;
|
|
1288
|
+
this.closed = true;
|
|
1289
|
+
this.stream.close();
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region lib/types/zstd-public-decoder.js
|
|
1294
|
+
/**
|
|
1295
|
+
* Public-API synchronous Zstandard frame decoder fallback.
|
|
1296
|
+
* @module dsh-session-persistence-jsonl/zstd-public-decoder
|
|
1297
|
+
*/
|
|
1298
|
+
/** Multi-frame adapter built exclusively from Node's supported one-shot API. */
|
|
1299
|
+
var PublicZstdFrameDecoder = class {
|
|
1300
|
+
started = false;
|
|
1301
|
+
closed = false;
|
|
1302
|
+
/** @inheritdoc */
|
|
1303
|
+
*decode(source, frames) {
|
|
1304
|
+
if (this.started) throw new Error("Zstandard frame decoder was already started");
|
|
1305
|
+
if (this.closed) throw new Error("cannot start a closed Zstandard frame decoder");
|
|
1306
|
+
this.started = true;
|
|
1307
|
+
try {
|
|
1308
|
+
for (const { start, end } of frames) {
|
|
1309
|
+
let decoded;
|
|
1310
|
+
try {
|
|
1311
|
+
decoded = zstdDecompressSync(source.subarray(start, end));
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
throw new Error(`corrupt Zstandard session log: frame at byte ${start} failed validation`, { cause: error });
|
|
1314
|
+
}
|
|
1315
|
+
yield decoded;
|
|
1316
|
+
}
|
|
1317
|
+
} finally {
|
|
1318
|
+
this.close();
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/** @inheritdoc */
|
|
1322
|
+
close() {
|
|
1323
|
+
this.closed = true;
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region lib/types/zstd.js
|
|
1328
|
+
/**
|
|
1329
|
+
* Zstandard frame primitives for the JSONL persistence backend. The backend
|
|
1330
|
+
* owns a concatenated-frame container so it can append and recover batches
|
|
1331
|
+
* without exposing compression mechanics through the persistence seam.
|
|
1332
|
+
* @module dsh-session-persistence-jsonl/zstd
|
|
1333
|
+
*/
|
|
1334
|
+
const ZSTD_MAGIC = 4247762216;
|
|
1335
|
+
const zstdCompressAsync = promisify(zstdCompress);
|
|
1336
|
+
const zstdDecompressAsync = promisify(zstdDecompress);
|
|
1337
|
+
const CHECKSUM_OPTIONS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
|
|
1338
|
+
const INCOMPLETE_FRAME_OPTIONS = { finishFlush: constants.ZSTD_e_flush };
|
|
1339
|
+
/**
|
|
1340
|
+
* Locate complete frames without decompressing their blocks. Invalid complete
|
|
1341
|
+
* structure rejects; EOF inside the final frame returns its start for repair.
|
|
1342
|
+
* @param buffer - complete bytes currently present in the session artifact.
|
|
1343
|
+
* @param maxFrames - optional complete-frame limit for metadata-only readers.
|
|
1344
|
+
* @returns complete frame ranges and an optional incomplete-final-frame start.
|
|
1345
|
+
*/
|
|
1346
|
+
function scanZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) {
|
|
1347
|
+
const frames = [];
|
|
1348
|
+
let offset = 0;
|
|
1349
|
+
while (offset < buffer.length) {
|
|
1350
|
+
const start = offset;
|
|
1351
|
+
if (buffer.length - offset < 4) return {
|
|
1352
|
+
frames,
|
|
1353
|
+
tornStart: start
|
|
1354
|
+
};
|
|
1355
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`);
|
|
1356
|
+
offset += 4;
|
|
1357
|
+
if (offset === buffer.length) return {
|
|
1358
|
+
frames,
|
|
1359
|
+
tornStart: start
|
|
1360
|
+
};
|
|
1361
|
+
const descriptor = buffer.readUInt8(offset);
|
|
1362
|
+
offset += 1;
|
|
1363
|
+
if ((descriptor & 24) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`);
|
|
1364
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
1365
|
+
const singleSegment = (descriptor & 32) !== 0;
|
|
1366
|
+
const checksum = (descriptor & 4) !== 0;
|
|
1367
|
+
const dictionaryFlag = descriptor & 3;
|
|
1368
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
|
1369
|
+
const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
|
|
1370
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
|
1371
|
+
if (buffer.length - offset < remainingHeaderBytes) return {
|
|
1372
|
+
frames,
|
|
1373
|
+
tornStart: start
|
|
1374
|
+
};
|
|
1375
|
+
offset += remainingHeaderBytes;
|
|
1376
|
+
for (;;) {
|
|
1377
|
+
if (buffer.length - offset < 3) return {
|
|
1378
|
+
frames,
|
|
1379
|
+
tornStart: start
|
|
1380
|
+
};
|
|
1381
|
+
const blockHeader = buffer.readUIntLE(offset, 3);
|
|
1382
|
+
offset += 3;
|
|
1383
|
+
const lastBlock = (blockHeader & 1) !== 0;
|
|
1384
|
+
const blockType = blockHeader >>> 1 & 3;
|
|
1385
|
+
const blockSize = blockHeader >>> 3;
|
|
1386
|
+
if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`);
|
|
1387
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize;
|
|
1388
|
+
if (buffer.length - offset < payloadBytes) return {
|
|
1389
|
+
frames,
|
|
1390
|
+
tornStart: start
|
|
1391
|
+
};
|
|
1392
|
+
offset += payloadBytes;
|
|
1393
|
+
if (lastBlock) break;
|
|
1394
|
+
}
|
|
1395
|
+
if (checksum) {
|
|
1396
|
+
if (buffer.length - offset < 4) return {
|
|
1397
|
+
frames,
|
|
1398
|
+
tornStart: start
|
|
1399
|
+
};
|
|
1400
|
+
offset += 4;
|
|
1401
|
+
}
|
|
1402
|
+
frames.push({
|
|
1403
|
+
start,
|
|
1404
|
+
end: offset
|
|
1405
|
+
});
|
|
1406
|
+
if (frames.length === maxFrames) return { frames };
|
|
1407
|
+
}
|
|
1408
|
+
return { frames };
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Compress one independently decodable, checksummed Zstandard frame.
|
|
1412
|
+
* @param input - JSONL bytes for a header or durable event batch.
|
|
1413
|
+
* @returns the complete encoded frame.
|
|
1414
|
+
*/
|
|
1415
|
+
async function compressZstdFrame(input) {
|
|
1416
|
+
return zstdCompressAsync(input, CHECKSUM_OPTIONS);
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Decompress one complete frame and validate its checksum.
|
|
1420
|
+
* @param input - one structurally complete Zstandard frame.
|
|
1421
|
+
* @returns the frame plaintext.
|
|
1422
|
+
*/
|
|
1423
|
+
async function decompressZstdFrame(input) {
|
|
1424
|
+
return zstdDecompressAsync(input);
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Select the shared private decoder when the running Node 22/24/26 shape is
|
|
1428
|
+
* compatible, otherwise preserve correctness with the public one-shot API.
|
|
1429
|
+
* @returns a synchronous decoder with an implementation-independent lifecycle.
|
|
1430
|
+
*/
|
|
1431
|
+
function createZstdFrameDecoder() {
|
|
1432
|
+
return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder();
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Recover available plaintext from a structurally incomplete final frame.
|
|
1436
|
+
* `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion;
|
|
1437
|
+
* callers must establish the torn frame boundary before using this helper.
|
|
1438
|
+
* @param input - available bytes from a known incomplete Zstandard frame.
|
|
1439
|
+
* @returns plaintext produced from the available input.
|
|
1440
|
+
*/
|
|
1441
|
+
async function decompressZstdPrefix(input) {
|
|
1442
|
+
return zstdDecompressAsync(input, INCOMPLETE_FRAME_OPTIONS);
|
|
1443
|
+
}
|
|
1444
|
+
//#endregion
|
|
1445
|
+
//#region lib/types/history-suffix.js
|
|
1446
|
+
/**
|
|
1447
|
+
* Tail-page decoder for JSONL session logs. It scans cheap Zstandard frame
|
|
1448
|
+
* boundaries and decompresses only the newest frames that cover one history
|
|
1449
|
+
* page, without constructing a {@link SessionLogScanner} from seq 0.
|
|
1450
|
+
*/
|
|
1451
|
+
const MESSAGE_TYPES = new Set(["user/message", "assistant/message"]);
|
|
1452
|
+
/**
|
|
1453
|
+
* Decode one history-page suffix from a current-generation JSONL artifact.
|
|
1454
|
+
* @param bytes - complete file bytes, possibly with a torn final Zstandard frame.
|
|
1455
|
+
* @param compression - physical encoding of this generation.
|
|
1456
|
+
* @param options - page bounds and cancellation.
|
|
1457
|
+
* @returns header, covering events, and the logical cursor.
|
|
1458
|
+
*/
|
|
1459
|
+
async function readJsonlHistorySuffix(bytes, compression, options) {
|
|
1460
|
+
return compression === "zstd" ? readZstdSuffix(bytes, options) : readPlainSuffix(bytes, options);
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Decode a Zstandard log by scanning frame boundaries and decompressing only
|
|
1464
|
+
* the newest event frames that cover the requested page.
|
|
1465
|
+
* @param bytes - concatenated frames, possibly with a torn final frame.
|
|
1466
|
+
* @param options - page bounds and cancellation.
|
|
1467
|
+
* @returns the covering suffix.
|
|
1468
|
+
*/
|
|
1469
|
+
async function readZstdSuffix(bytes, options) {
|
|
1470
|
+
options.signal?.throwIfAborted();
|
|
1471
|
+
const { frames, tornStart } = scanZstdFrames(bytes);
|
|
1472
|
+
const headerFrame = frames[0];
|
|
1473
|
+
if (headerFrame === void 0) throw new Error("empty or header-less Zstandard session log");
|
|
1474
|
+
const headerPlain = await decompressZstdFrame(bytes.subarray(headerFrame.start, headerFrame.end));
|
|
1475
|
+
assertZstdHeaderFrame$1(headerPlain);
|
|
1476
|
+
const header = parseHeaderRecord(headerPlain).meta;
|
|
1477
|
+
const eventFrames = frames.slice(1);
|
|
1478
|
+
const collected = [];
|
|
1479
|
+
if (tornStart !== void 0) {
|
|
1480
|
+
options.signal?.throwIfAborted();
|
|
1481
|
+
let recovered = Buffer.alloc(0);
|
|
1482
|
+
try {
|
|
1483
|
+
recovered = Buffer.from(await decompressZstdPrefix(bytes.subarray(tornStart)));
|
|
1484
|
+
} catch {
|
|
1485
|
+
if (options.signal?.aborted === true) options.signal.throwIfAborted();
|
|
1486
|
+
}
|
|
1487
|
+
const tornEvents = parseEventRecords(recovered);
|
|
1488
|
+
if (tornEvents.length > 0) collected.push(tornEvents);
|
|
1489
|
+
}
|
|
1490
|
+
if (eventFrames.length > 0) {
|
|
1491
|
+
const last = eventFrames[eventFrames.length - 1];
|
|
1492
|
+
/* v8 ignore next -- length > 0 guarantees a last complete event frame. */
|
|
1493
|
+
if (last === void 0) throw new Error("empty or header-less Zstandard session log");
|
|
1494
|
+
options.signal?.throwIfAborted();
|
|
1495
|
+
collected.unshift(parseEventRecords(await decompressZstdFrame(bytes.subarray(last.start, last.end))));
|
|
1496
|
+
for (let index = eventFrames.length - 2; index >= 0; index -= 1) {
|
|
1497
|
+
if (suffixComplete(collected.flat(), options)) break;
|
|
1498
|
+
const frame = eventFrames[index];
|
|
1499
|
+
/* v8 ignore next -- the countdown stays inside the scanned event-frame list. */
|
|
1500
|
+
if (frame === void 0) continue;
|
|
1501
|
+
options.signal?.throwIfAborted();
|
|
1502
|
+
collected.unshift(parseEventRecords(await decompressZstdFrame(bytes.subarray(frame.start, frame.end))));
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return finishSuffix(header, collected.flat(), options.signal);
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Walk complete JSONL records from the end of an uncompressed log.
|
|
1509
|
+
* @param bytes - header line plus event rows, possibly with a torn final line.
|
|
1510
|
+
* @param options - page bounds and cancellation.
|
|
1511
|
+
* @returns the covering suffix.
|
|
1512
|
+
*/
|
|
1513
|
+
function readPlainSuffix(bytes, options) {
|
|
1514
|
+
options.signal?.throwIfAborted();
|
|
1515
|
+
const headerEnd = bytes.indexOf(10);
|
|
1516
|
+
if (headerEnd === -1) throw new Error("empty or header-less session log");
|
|
1517
|
+
const header = parseHeaderRecord(bytes.subarray(0, headerEnd + 1)).meta;
|
|
1518
|
+
const body = bytes.subarray(headerEnd + 1);
|
|
1519
|
+
const records = [];
|
|
1520
|
+
let lineEnd = lastCompleteRecordEnd(body);
|
|
1521
|
+
while (lineEnd > 0) {
|
|
1522
|
+
options.signal?.throwIfAborted();
|
|
1523
|
+
const previous = lineEnd <= 1 ? -1 : body.lastIndexOf(10, lineEnd - 2);
|
|
1524
|
+
const lineStart = previous === -1 ? 0 : previous + 1;
|
|
1525
|
+
records.unshift(...parseEventRecords(body.subarray(lineStart, lineEnd)));
|
|
1526
|
+
if (suffixComplete(records, options)) break;
|
|
1527
|
+
if (lineStart === 0) break;
|
|
1528
|
+
lineEnd = lineStart;
|
|
1529
|
+
}
|
|
1530
|
+
return Promise.resolve(finishSuffix(header, records, options.signal));
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Attach in-memory interrupted-turn closers when the open turn started in this
|
|
1534
|
+
* suffix, and report the logical cursor including those closers.
|
|
1535
|
+
* @param header - parsed current-generation header.
|
|
1536
|
+
* @param events - dense durable suffix events in seq order.
|
|
1537
|
+
* @param signal - optional cancellation.
|
|
1538
|
+
* @returns the persistence suffix view.
|
|
1539
|
+
*/
|
|
1540
|
+
function finishSuffix(header, events, signal) {
|
|
1541
|
+
signal?.throwIfAborted();
|
|
1542
|
+
const closers = closersForSuffix(events);
|
|
1543
|
+
const logical = closers.length === 0 ? events : [...events, ...closers];
|
|
1544
|
+
return {
|
|
1545
|
+
header,
|
|
1546
|
+
inheritedEventCount: SessionLogOffset(0),
|
|
1547
|
+
events: logical,
|
|
1548
|
+
cursor: logical.at(-1)?.seq ?? -1
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Synthesize crash closers only when this suffix contains the still-open
|
|
1553
|
+
* `turn/start`. An older open turn whose start sits before the page is left
|
|
1554
|
+
* unbalanced so the reader does not invent a cut that is not in the window.
|
|
1555
|
+
* @param events - durable suffix events.
|
|
1556
|
+
* @returns synthetic closers, or none.
|
|
1557
|
+
*/
|
|
1558
|
+
function closersForSuffix(events) {
|
|
1559
|
+
let openTurnStartedHere = false;
|
|
1560
|
+
let open = false;
|
|
1561
|
+
for (const event of events) if (event.type === "turn/start") {
|
|
1562
|
+
open = true;
|
|
1563
|
+
openTurnStartedHere = true;
|
|
1564
|
+
} else if (event.type === "turn/end") {
|
|
1565
|
+
open = false;
|
|
1566
|
+
openTurnStartedHere = false;
|
|
1567
|
+
}
|
|
1568
|
+
return open && openTurnStartedHere ? interruptedTurnClosers(events) : [];
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Whether `events` already covers the requested page, including the case
|
|
1572
|
+
* where the suffix reaches seq 0.
|
|
1573
|
+
* @param events - currently decoded dense suffix.
|
|
1574
|
+
* @param options - page bounds.
|
|
1575
|
+
* @returns true when no older frame or line is required.
|
|
1576
|
+
*/
|
|
1577
|
+
function suffixComplete(events, options) {
|
|
1578
|
+
if (options.throughSeq === -1) return true;
|
|
1579
|
+
if (events.length === 0) return false;
|
|
1580
|
+
const origin = events[0]?.seq;
|
|
1581
|
+
/* v8 ignore next -- parseEventRecords only pushes events that carry seq. */
|
|
1582
|
+
if (origin === void 0) return false;
|
|
1583
|
+
const last = events.at(-1)?.seq;
|
|
1584
|
+
/* v8 ignore next -- a non-empty suffix has a last event. */
|
|
1585
|
+
if (last === void 0) return false;
|
|
1586
|
+
const through = options.throughSeq ?? last;
|
|
1587
|
+
const endSeq = Math.min(Math.min(through, last), (options.beforeSeq ?? through + 1) - 1);
|
|
1588
|
+
if (endSeq < origin) return origin === 0;
|
|
1589
|
+
let count = 0;
|
|
1590
|
+
let cutSeq = origin;
|
|
1591
|
+
let hitMax = false;
|
|
1592
|
+
for (let seq = endSeq; seq >= origin; seq -= 1) {
|
|
1593
|
+
const event = events[seq - origin];
|
|
1594
|
+
if (event === void 0 || event.seq !== seq) throw new Error(`corrupt session log: suffix is not dense at seq ${String(seq)}`);
|
|
1595
|
+
const groupStart = messageGroupStart(event);
|
|
1596
|
+
if (groupStart === void 0) continue;
|
|
1597
|
+
count += 1;
|
|
1598
|
+
if (count >= options.maxMessages) {
|
|
1599
|
+
cutSeq = groupStart;
|
|
1600
|
+
hitMax = true;
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (!hitMax) return origin === 0;
|
|
1605
|
+
return cutSeq >= origin;
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Inclusive start seq of one append-surface user/assistant message group.
|
|
1609
|
+
* @param event - candidate journal event.
|
|
1610
|
+
* @returns the earliest owned source seq, or `undefined` when the event does
|
|
1611
|
+
* not count toward `maxMessages`.
|
|
1612
|
+
*/
|
|
1613
|
+
function messageGroupStart(event) {
|
|
1614
|
+
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) return void 0;
|
|
1615
|
+
let groupStart = event.seq;
|
|
1616
|
+
const sources = event.sourceEventSeqs;
|
|
1617
|
+
if (sources !== void 0) {
|
|
1618
|
+
for (const source of sources) if (source < groupStart) groupStart = source;
|
|
1619
|
+
}
|
|
1620
|
+
return groupStart;
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Parse complete JSONL event rows from one plaintext buffer, skipping a
|
|
1624
|
+
* nested session header if a frame accidentally repeats it.
|
|
1625
|
+
* @param plaintext - zero or more newline-terminated records.
|
|
1626
|
+
* @returns decoded events in file order.
|
|
1627
|
+
*/
|
|
1628
|
+
function parseEventRecords(plaintext) {
|
|
1629
|
+
const events = [];
|
|
1630
|
+
let start = 0;
|
|
1631
|
+
for (let index = 0; index < plaintext.length; index += 1) {
|
|
1632
|
+
if (plaintext[index] !== 10) continue;
|
|
1633
|
+
const line = plaintext.subarray(start, index);
|
|
1634
|
+
start = index + 1;
|
|
1635
|
+
if (line.length === 0) continue;
|
|
1636
|
+
let parsed;
|
|
1637
|
+
try {
|
|
1638
|
+
parsed = JSON.parse(line.toString("utf8"));
|
|
1639
|
+
} catch {
|
|
1640
|
+
throw new Error("corrupt session log: unparsable committed event");
|
|
1641
|
+
}
|
|
1642
|
+
if (typeof parsed === "object" && parsed !== null && parsed.type === "session") continue;
|
|
1643
|
+
events.push(decodeSuffixEvent(parsed));
|
|
1644
|
+
}
|
|
1645
|
+
return events;
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Admit one current-generation event row and expand compressed source ranges.
|
|
1649
|
+
* @param parsed - JSON value of one event line.
|
|
1650
|
+
* @returns the logical event used for pagination.
|
|
1651
|
+
*/
|
|
1652
|
+
function decodeSuffixEvent(parsed) {
|
|
1653
|
+
assertV3RowAdmission(parsed);
|
|
1654
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("corrupt session log: event row is not a JSON object");
|
|
1655
|
+
const record = parsed;
|
|
1656
|
+
if (record["sourceEventSeqs"] === void 0) return record;
|
|
1657
|
+
const seq = SessionSeq(record["seq"]);
|
|
1658
|
+
return {
|
|
1659
|
+
...record,
|
|
1660
|
+
seq,
|
|
1661
|
+
sourceEventSeqs: decodeSeqRanges(record["sourceEventSeqs"], seq)
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Exclusive end of the last newline-terminated record.
|
|
1666
|
+
* @param buffer - event-body bytes after the header line.
|
|
1667
|
+
* @returns 0 when no complete record exists.
|
|
1668
|
+
*/
|
|
1669
|
+
function lastCompleteRecordEnd(buffer) {
|
|
1670
|
+
const last = buffer.lastIndexOf(10);
|
|
1671
|
+
return last === -1 ? 0 : last + 1;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Require the first Zstandard frame to contain exactly the header record.
|
|
1675
|
+
* @param plaintext - decompressed first frame.
|
|
1676
|
+
*/
|
|
1677
|
+
function assertZstdHeaderFrame$1(plaintext) {
|
|
1678
|
+
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");
|
|
1679
|
+
}
|
|
1680
|
+
//#endregion
|
|
1681
|
+
//#region lib/types/migration-verifier.js
|
|
1682
|
+
/** Isolated verification for a staged or competing current JSONL generation. */
|
|
1683
|
+
/** Process-wide memory bound for full-generation verification isolates. */
|
|
1684
|
+
const MAX_CONCURRENT_VERIFIERS = 2;
|
|
1685
|
+
var VerificationScheduler = class {
|
|
1686
|
+
active = 0;
|
|
1687
|
+
waiting = [];
|
|
1688
|
+
async run(operation, signal) {
|
|
1689
|
+
const permit = this.acquire(signal);
|
|
1690
|
+
if (permit !== void 0) await permit;
|
|
1691
|
+
try {
|
|
1692
|
+
signal?.throwIfAborted();
|
|
1693
|
+
return await operation();
|
|
1694
|
+
} finally {
|
|
1695
|
+
this.release();
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
acquire(signal) {
|
|
1699
|
+
signal?.throwIfAborted();
|
|
1700
|
+
if (this.active < MAX_CONCURRENT_VERIFIERS) {
|
|
1701
|
+
this.active += 1;
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
return new Promise((resolve, reject) => {
|
|
1705
|
+
const waiter = { grant: () => {
|
|
1706
|
+
signal?.removeEventListener("abort", abort);
|
|
1707
|
+
resolve();
|
|
1708
|
+
} };
|
|
1709
|
+
const abort = () => {
|
|
1710
|
+
const index = this.waiting.indexOf(waiter);
|
|
1711
|
+
this.waiting.splice(index, 1);
|
|
1712
|
+
reject(verifierAbortError(signal));
|
|
1713
|
+
};
|
|
1714
|
+
this.waiting.push(waiter);
|
|
1715
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
release() {
|
|
1719
|
+
const next = this.waiting.shift();
|
|
1720
|
+
if (next === void 0) {
|
|
1721
|
+
this.active -= 1;
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
next.grant();
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
const verificationScheduler = new VerificationScheduler();
|
|
1728
|
+
function workerSpawn(request) {
|
|
1729
|
+
/* v8 ignore next 3 -- built-worker coverage owns the bundled path. */
|
|
1730
|
+
if (!import.meta.url.endsWith(".ts")) return {
|
|
1731
|
+
entry: new URL("./worker.cjs", import.meta.url),
|
|
1732
|
+
options: {
|
|
1733
|
+
workerData: request,
|
|
1734
|
+
execArgv: []
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
const workerEntry = new URL("./worker.ts", import.meta.url);
|
|
1738
|
+
const bootstrap = [
|
|
1739
|
+
`import { register as registerEsm } from ${JSON.stringify(import.meta.resolve("tsx/esm/api"))}`,
|
|
1740
|
+
`import { register as registerCjs } from ${JSON.stringify(import.meta.resolve("tsx/cjs/api"))}`,
|
|
1741
|
+
"registerCjs()",
|
|
1742
|
+
"registerEsm()",
|
|
1743
|
+
`await import(${JSON.stringify(workerEntry.href)})`
|
|
1744
|
+
].join("\n");
|
|
1745
|
+
return {
|
|
1746
|
+
entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`),
|
|
1747
|
+
options: {
|
|
1748
|
+
workerData: request,
|
|
1749
|
+
execArgv: []
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Verify one current generation in a fresh Worker Thread.
|
|
1755
|
+
* @param path - staged or competing current-generation path.
|
|
1756
|
+
* @param compression - configured physical encoding.
|
|
1757
|
+
* @param expectedId - Session id expected in the decoded header.
|
|
1758
|
+
* @param expectedEventCount - exact logical event count expected after decoding.
|
|
1759
|
+
* @param expectedPrefix - verified physical prefix; an append tail may be present and is not validated.
|
|
1760
|
+
* @param signal - optional cancellation for scheduler wait and Worker execution.
|
|
1761
|
+
* @returns stable physical identity and digest observed by the worker.
|
|
1762
|
+
*/
|
|
1763
|
+
function verifyCurrentGenerationInWorker(path, compression, expectedId, expectedEventCount, expectedPrefix, signal) {
|
|
1764
|
+
return verificationScheduler.run(() => runVerificationWorker(path, compression, expectedId, expectedEventCount, expectedPrefix, signal), signal);
|
|
1765
|
+
}
|
|
1766
|
+
function runVerificationWorker(path, compression, expectedId, expectedEventCount, expectedPrefix, signal) {
|
|
1767
|
+
signal?.throwIfAborted();
|
|
1768
|
+
const { entry, options } = workerSpawn({
|
|
1769
|
+
path,
|
|
1770
|
+
compression,
|
|
1771
|
+
expectedId,
|
|
1772
|
+
expectedEventCount,
|
|
1773
|
+
...expectedPrefix === void 0 ? {} : { expectedPrefix }
|
|
1774
|
+
});
|
|
1775
|
+
const worker = new Worker(entry, options);
|
|
1776
|
+
return new Promise((resolve, reject) => {
|
|
1777
|
+
let settled = false;
|
|
1778
|
+
const cleanup = () => {
|
|
1779
|
+
signal?.removeEventListener("abort", abort);
|
|
1780
|
+
};
|
|
1781
|
+
const fail = (error) => {
|
|
1782
|
+
/* v8 ignore next -- a late error/exit races only after another terminal callback settled. */
|
|
1783
|
+
if (settled) return;
|
|
1784
|
+
settled = true;
|
|
1785
|
+
cleanup();
|
|
1786
|
+
worker.terminate().then(() => {
|
|
1787
|
+
reject(error);
|
|
1788
|
+
}, (cleanup) => {
|
|
1789
|
+
reject(new AggregateError([error, cleanup], "migration verifier termination failed"));
|
|
1790
|
+
});
|
|
1791
|
+
};
|
|
1792
|
+
worker.once("message", (value) => {
|
|
1793
|
+
/* v8 ignore next -- a duplicate message races only after another terminal callback settled. */
|
|
1794
|
+
if (settled) return;
|
|
1795
|
+
if (typeof value !== "object" || value === null || typeof value.ok !== "boolean") {
|
|
1796
|
+
fail(/* @__PURE__ */ new Error("migration verifier returned an invalid response"));
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
const response = value;
|
|
1800
|
+
if (!response.ok) {
|
|
1801
|
+
const error = new Error(response.message);
|
|
1802
|
+
if (response.stack !== void 0) error.stack = response.stack;
|
|
1803
|
+
fail(error);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
settled = true;
|
|
1807
|
+
cleanup();
|
|
1808
|
+
worker.terminate().then(() => {
|
|
1809
|
+
resolve(response.result);
|
|
1810
|
+
}, (error) => {
|
|
1811
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1812
|
+
});
|
|
1813
|
+
});
|
|
1814
|
+
worker.once("error", fail);
|
|
1815
|
+
worker.once("exit", (code) => {
|
|
1816
|
+
if (!settled) fail(/* @__PURE__ */ new Error(`migration verifier exited before reporting a result (code ${code})`));
|
|
1817
|
+
});
|
|
1818
|
+
const abort = () => {
|
|
1819
|
+
fail(verifierAbortError(signal));
|
|
1820
|
+
};
|
|
1821
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
function verifierAbortError(signal) {
|
|
1825
|
+
const reason = signal?.reason;
|
|
1826
|
+
return reason instanceof Error ? reason : new Error("migration verifier aborted", { cause: reason });
|
|
1827
|
+
}
|
|
1828
|
+
//#endregion
|
|
1829
|
+
//#region lib/types/generation.js
|
|
1830
|
+
/**
|
|
1831
|
+
* Durable whole-generation publication for JSONL Session artifacts.
|
|
1832
|
+
*
|
|
1833
|
+
* Format packages transform parsed JSON values. This module owns the physical
|
|
1834
|
+
* encoding, exact source identity, immutable generation files, and exclusive
|
|
1835
|
+
* current-generation publication for both configured JSONL suffixes.
|
|
1836
|
+
* @module @x1a0f3n9/dsh-session-persistence-jsonl/generation
|
|
1837
|
+
*/
|
|
1838
|
+
/** Internal scheduling bounds: preserve old decode cadence and cap each synchronous encode slice. */
|
|
1839
|
+
const MIGRATION_DECODE_YIELD_INTERVAL_MS = 500;
|
|
1840
|
+
const MIGRATION_WORK_CHUNK_BYTES = 1024 * 1024;
|
|
1841
|
+
const MIGRATION_WRITE_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
1842
|
+
const ZSTD_CHECKSUM_OPTIONS = {
|
|
1843
|
+
chunkSize: MIGRATION_WORK_CHUNK_BYTES,
|
|
1844
|
+
params: { [constants.ZSTD_c_checksumFlag]: 1 }
|
|
1845
|
+
};
|
|
1846
|
+
/** A historical source changed after its single decode and migration pass. */
|
|
1847
|
+
var JsonlGenerationSourceChangedError = class extends Error {
|
|
1848
|
+
path;
|
|
1849
|
+
name = "JsonlGenerationSourceChangedError";
|
|
1850
|
+
/** @param path - historical generation whose revision changed. */
|
|
1851
|
+
constructor(path) {
|
|
1852
|
+
super(`historical session generation changed during migration: "${path}"`);
|
|
1853
|
+
this.path = path;
|
|
1854
|
+
}
|
|
1855
|
+
};
|
|
1856
|
+
/** A historical artifact is intact, but the format edge refuses its contents. */
|
|
1857
|
+
var JsonlGenerationUnsupportedMigrationError = class extends Error {
|
|
1858
|
+
fromVersion;
|
|
1859
|
+
reason;
|
|
1860
|
+
name = "JsonlGenerationUnsupportedMigrationError";
|
|
1861
|
+
/**
|
|
1862
|
+
* @param fromVersion - unchanged source generation version.
|
|
1863
|
+
* @param reason - format-edge refusal.
|
|
1864
|
+
*/
|
|
1865
|
+
constructor(fromVersion, reason) {
|
|
1866
|
+
super(reason.message, { cause: reason });
|
|
1867
|
+
this.fromVersion = fromVersion;
|
|
1868
|
+
this.reason = reason;
|
|
1869
|
+
}
|
|
1870
|
+
};
|
|
1871
|
+
/** A current-generation filename already names different or invalid bytes. */
|
|
1872
|
+
var JsonlGenerationTargetConflictError = class extends Error {
|
|
1873
|
+
path;
|
|
1874
|
+
reason;
|
|
1875
|
+
name = "JsonlGenerationTargetConflictError";
|
|
1876
|
+
/**
|
|
1877
|
+
* @param path - immutable target that prevented exclusive publication.
|
|
1878
|
+
* @param reason - why the existing target cannot be accepted.
|
|
1879
|
+
*/
|
|
1880
|
+
constructor(path, reason) {
|
|
1881
|
+
super(`current session generation already exists at "${path}": ${reason.message}`, { cause: reason });
|
|
1882
|
+
this.path = path;
|
|
1883
|
+
this.reason = reason;
|
|
1884
|
+
}
|
|
1885
|
+
};
|
|
1886
|
+
const defaultFileSystem = {
|
|
1887
|
+
open: (path, flags, mode) => open(path, flags, mode),
|
|
1888
|
+
readFile: (path, signal) => readFile(path, signal === void 0 ? void 0 : { signal }),
|
|
1889
|
+
readdir: (path) => readdir(path),
|
|
1890
|
+
stat: (path) => stat(path, { bigint: true }),
|
|
1891
|
+
lstat: (path) => lstat(path),
|
|
1892
|
+
link,
|
|
1893
|
+
rm: (path) => rm(path, { force: true })
|
|
1894
|
+
};
|
|
1895
|
+
const defaultInternals = {
|
|
1896
|
+
fs: defaultFileSystem,
|
|
1897
|
+
randomToken: () => randomBytes(8).toString("hex"),
|
|
1898
|
+
platform: process.platform,
|
|
1899
|
+
publishNewWin32: publishNewFileWin32,
|
|
1900
|
+
barrier: () => {}
|
|
1901
|
+
};
|
|
1902
|
+
function isEEXIST(error) {
|
|
1903
|
+
return error?.code === "EEXIST";
|
|
1904
|
+
}
|
|
1905
|
+
/** Whether a filesystem-owned failure should retain its original errno and path. */
|
|
1906
|
+
function isErrnoException$1(error) {
|
|
1907
|
+
return typeof error?.code === "string";
|
|
1908
|
+
}
|
|
1909
|
+
function identity(value) {
|
|
1910
|
+
return [
|
|
1911
|
+
value.dev,
|
|
1912
|
+
value.ino,
|
|
1913
|
+
value.size,
|
|
1914
|
+
value.mtimeNs,
|
|
1915
|
+
value.ctimeNs
|
|
1916
|
+
].join(":");
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Read one stable revision of a JSONL file with a single retry. If an append
|
|
1920
|
+
* overlaps both reads, return the second read's committed pre-read prefix
|
|
1921
|
+
* instead of starving behind a continuous writer.
|
|
1922
|
+
* @param path - the generation file to read.
|
|
1923
|
+
* @param signal - optional cancellation for the stat/read work.
|
|
1924
|
+
* @returns the stable bytes (or the committed prefix) and their stat identity.
|
|
1925
|
+
*/
|
|
1926
|
+
async function readStableJsonlFile(path, signal) {
|
|
1927
|
+
return defaultGenerationRuntime.readStable(path, signal);
|
|
1928
|
+
}
|
|
1929
|
+
async function readStableSnapshot(path, signal, fs) {
|
|
1930
|
+
signal?.throwIfAborted();
|
|
1931
|
+
let before = await fs.stat(path);
|
|
1932
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1933
|
+
const bytes = await fs.readFile(path, signal);
|
|
1934
|
+
signal?.throwIfAborted();
|
|
1935
|
+
const after = await fs.stat(path);
|
|
1936
|
+
if (identity(before) === identity(after)) {
|
|
1937
|
+
signal?.throwIfAborted();
|
|
1938
|
+
return {
|
|
1939
|
+
bytes,
|
|
1940
|
+
identity: after
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
if (attempt === 1) return {
|
|
1944
|
+
bytes: bytes.subarray(0, Number(before.size)),
|
|
1945
|
+
identity: before
|
|
1946
|
+
};
|
|
1947
|
+
before = after;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
/** Parse the version discriminator without validating any version-specific field. */
|
|
1951
|
+
function storedVersion(header) {
|
|
1952
|
+
if (typeof header !== "object" || header === null || Array.isArray(header)) throw new Error("corrupt session log: first line is not a JSON object");
|
|
1953
|
+
const version = header.version;
|
|
1954
|
+
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");
|
|
1955
|
+
return version;
|
|
1956
|
+
}
|
|
1957
|
+
function parseJson(text, subject) {
|
|
1958
|
+
try {
|
|
1959
|
+
return JSON.parse(text);
|
|
1960
|
+
} catch (error) {
|
|
1961
|
+
throw new Error(`corrupt session log: ${subject} is not valid JSON`, { cause: error });
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
/** Incremental JSONL parser that retains only one cross-frame record fragment. */
|
|
1965
|
+
var MigratingJsonlRows = class {
|
|
1966
|
+
restore;
|
|
1967
|
+
fragments = [];
|
|
1968
|
+
fragmentBytes = 0;
|
|
1969
|
+
rowIndex = 0;
|
|
1970
|
+
issue;
|
|
1971
|
+
constructor(restore) {
|
|
1972
|
+
this.restore = restore;
|
|
1973
|
+
}
|
|
1974
|
+
/** Consume plaintext bytes following the independently decoded header. */
|
|
1975
|
+
write(chunk) {
|
|
1976
|
+
let lineStart = 0;
|
|
1977
|
+
for (let newline = chunk.indexOf(10); newline !== -1; newline = chunk.indexOf(10, lineStart)) {
|
|
1978
|
+
const fragment = chunk.subarray(lineStart, newline);
|
|
1979
|
+
let line = fragment;
|
|
1980
|
+
if (this.fragments.length > 0) {
|
|
1981
|
+
if (fragment.length > 0) this.fragments.push(fragment);
|
|
1982
|
+
line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length);
|
|
1983
|
+
this.fragments = [];
|
|
1984
|
+
this.fragmentBytes = 0;
|
|
1985
|
+
}
|
|
1986
|
+
this.consume(line);
|
|
1987
|
+
lineStart = newline + 1;
|
|
1988
|
+
}
|
|
1989
|
+
if (lineStart < chunk.length) {
|
|
1990
|
+
const fragment = Buffer.from(chunk.subarray(lineStart));
|
|
1991
|
+
this.fragments.push(fragment);
|
|
1992
|
+
this.fragmentBytes += fragment.length;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
/** Refuse a record fragment left by structurally complete Zstandard frames. */
|
|
1996
|
+
assertCompleteFramesEndOnRecord() {
|
|
1997
|
+
if (this.fragments.length > 0) throw new Error("corrupt Zstandard session log: complete frame contains a torn JSONL record");
|
|
1998
|
+
}
|
|
1999
|
+
finish() {
|
|
2000
|
+
return this.restore.finish();
|
|
2001
|
+
}
|
|
2002
|
+
consume(line) {
|
|
2003
|
+
const index = this.rowIndex;
|
|
2004
|
+
this.rowIndex += 1;
|
|
2005
|
+
let row;
|
|
2006
|
+
try {
|
|
2007
|
+
row = parseJson(line.toString("utf8"), `row ${index + 1}`);
|
|
2008
|
+
} catch (error) {
|
|
2009
|
+
this.issue ??= asError(error);
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
if (this.issue !== void 0) {
|
|
2013
|
+
if (typeof row === "object" && row !== null && row.type === "turn/end") throw this.issue;
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
this.restore.decodeRow(row);
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
async function startMigrationStream(headerRecord, sourceVersion, format, validateHistoricalHeader) {
|
|
2020
|
+
const value = parseJson(headerRecord.subarray(0, -1).toString("utf8"), "header line");
|
|
2021
|
+
const version = storedVersion(value);
|
|
2022
|
+
if (version !== sourceVersion) throw new Error(`resolved JSONL source filename identifies v${sourceVersion}, but its header identifies v${version}`);
|
|
2023
|
+
const header = value;
|
|
2024
|
+
const validation = validateHistoricalHeader?.(header);
|
|
2025
|
+
if (validation !== void 0) await validation;
|
|
2026
|
+
return { parser: new MigratingJsonlRows(format.createRestore(header)) };
|
|
2027
|
+
}
|
|
2028
|
+
async function consumeMigrationBytes(rows, chunks, signal) {
|
|
2029
|
+
signal?.throwIfAborted();
|
|
2030
|
+
let yieldDeadline = performance.now() + MIGRATION_DECODE_YIELD_INTERVAL_MS;
|
|
2031
|
+
for (const bytes of chunks) for (let offset = 0; offset < bytes.length; offset += MIGRATION_WORK_CHUNK_BYTES) {
|
|
2032
|
+
rows.write(bytes.subarray(offset, offset + MIGRATION_WORK_CHUNK_BYTES));
|
|
2033
|
+
if (performance.now() < yieldDeadline) continue;
|
|
2034
|
+
await scheduler.yield();
|
|
2035
|
+
signal?.throwIfAborted();
|
|
2036
|
+
yieldDeadline = performance.now() + MIGRATION_DECODE_YIELD_INTERVAL_MS;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
async function decodeStreamingMigration(bytes, compression, sourceVersion, format, validateHistoricalHeader, signal) {
|
|
2040
|
+
signal?.throwIfAborted();
|
|
2041
|
+
if (compression === "none") {
|
|
2042
|
+
const headerEnd = bytes.indexOf(10);
|
|
2043
|
+
if (headerEnd === -1) throw new Error("empty or header-less session log");
|
|
2044
|
+
const stream = await startMigrationStream(bytes.subarray(0, headerEnd + 1), sourceVersion, format, validateHistoricalHeader);
|
|
2045
|
+
signal?.throwIfAborted();
|
|
2046
|
+
const bodyEnd = bytes.lastIndexOf(10);
|
|
2047
|
+
if (bodyEnd > headerEnd) await consumeMigrationBytes(stream.parser, [bytes.subarray(headerEnd + 1, bodyEnd + 1)], signal);
|
|
2048
|
+
return stream.parser.finish();
|
|
2049
|
+
}
|
|
2050
|
+
const { frames, tornStart } = scanZstdFrames(bytes);
|
|
2051
|
+
if (frames.length === 0) throw new Error("empty or header-less Zstandard session log");
|
|
2052
|
+
const decoder = createZstdFrameDecoder();
|
|
2053
|
+
try {
|
|
2054
|
+
const decoded = decoder.decode(bytes, frames);
|
|
2055
|
+
const first = decoded.next();
|
|
2056
|
+
/* v8 ignore next -- a non-empty structural frame list yields once or throws. */
|
|
2057
|
+
if (first.done) throw new Error("empty or header-less Zstandard session log");
|
|
2058
|
+
assertIndependentHeaderFrame(first.value);
|
|
2059
|
+
const stream = await startMigrationStream(first.value, sourceVersion, format, validateHistoricalHeader);
|
|
2060
|
+
signal?.throwIfAborted();
|
|
2061
|
+
await consumeMigrationBytes(stream.parser, decoded, signal);
|
|
2062
|
+
stream.parser.assertCompleteFramesEndOnRecord();
|
|
2063
|
+
if (tornStart !== void 0) {
|
|
2064
|
+
let recovered = Buffer.alloc(0);
|
|
2065
|
+
try {
|
|
2066
|
+
recovered = await decompressZstdPrefix(bytes.subarray(tornStart));
|
|
2067
|
+
} catch {
|
|
2068
|
+
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent. */
|
|
2069
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
2070
|
+
}
|
|
2071
|
+
signal?.throwIfAborted();
|
|
2072
|
+
const newline = recovered.lastIndexOf(10);
|
|
2073
|
+
if (newline !== -1) await consumeMigrationBytes(stream.parser, [recovered.subarray(0, newline + 1)], signal);
|
|
2074
|
+
}
|
|
2075
|
+
return stream.parser.finish();
|
|
2076
|
+
} finally {
|
|
2077
|
+
decoder.close();
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
async function verifyCurrentGeneration(path, compression, expectedId, expectedEventCount, fs, expectedPrefix) {
|
|
2081
|
+
const before = await fs.stat(path);
|
|
2082
|
+
const bytes = await fs.readFile(path);
|
|
2083
|
+
const after = await fs.stat(path);
|
|
2084
|
+
if (expectedPrefix !== void 0) {
|
|
2085
|
+
if (bytes.length < expectedPrefix.bytes) throw new Error("target bytes are shorter than the migrated generation");
|
|
2086
|
+
const digest = createHash("sha256").update(bytes.subarray(0, expectedPrefix.bytes)).digest("hex");
|
|
2087
|
+
if (digest !== expectedPrefix.digest) throw new Error("target bytes do not begin with the migrated generation");
|
|
2088
|
+
return {
|
|
2089
|
+
identity: after,
|
|
2090
|
+
bytes: expectedPrefix.bytes,
|
|
2091
|
+
digest
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
if (identity(before) !== identity(after)) throw new Error("current session generation changed during verification");
|
|
2095
|
+
const snapshot = {
|
|
2096
|
+
bytes,
|
|
2097
|
+
identity: after
|
|
2098
|
+
};
|
|
2099
|
+
const generation = decodeCurrentGeneration(snapshot.bytes, compression);
|
|
2100
|
+
validateStoredEvents(generation.meta, generation.events, {
|
|
2101
|
+
kind: "jsonl",
|
|
2102
|
+
path
|
|
2103
|
+
});
|
|
2104
|
+
if (generation.meta.id !== expectedId) throw new Error(`current session generation contains id "${generation.meta.id}", expected "${expectedId}"`);
|
|
2105
|
+
if (generation.events.length !== expectedEventCount) throw new Error(`current session generation contains ${generation.events.length} events, expected ${expectedEventCount}`);
|
|
2106
|
+
Session.fromRestore(generation.meta.id, generation.events, generation.meta, generation.inheritedEventCount, "detached");
|
|
2107
|
+
assertCurrentAssistantStreams(generation.events);
|
|
2108
|
+
return {
|
|
2109
|
+
identity: snapshot.identity,
|
|
2110
|
+
bytes: snapshot.bytes.length,
|
|
2111
|
+
digest: createHash("sha256").update(snapshot.bytes).digest("hex")
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
/** Fully replay embedded streams only inside isolated current-generation verification. */
|
|
2115
|
+
function assertCurrentAssistantStreams(events) {
|
|
2116
|
+
for (const [index, event] of events.entries()) {
|
|
2117
|
+
if (event.type !== "assistant/message" && event.type !== "assistant/attempt") continue;
|
|
2118
|
+
const assembler = new BlockAssembler();
|
|
2119
|
+
let timed;
|
|
2120
|
+
try {
|
|
2121
|
+
timed = expandAssistantStream(event.data.stream);
|
|
2122
|
+
for (const member of timed) assembler.push(member.chunk);
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
throw new Error(`seed ${event.type} at index ${index} has an invalid embedded stream`, { cause: error });
|
|
2125
|
+
}
|
|
2126
|
+
if (event.type === "assistant/attempt" || timed.length === 0) continue;
|
|
2127
|
+
const content = event.data.interrupted === true ? assembler.interruptedBlocks() : assembler.blocks();
|
|
2128
|
+
if (!isDeepStrictEqual(event.data.message.content, content)) throw new Error(`seed assistant/message at index ${index} content disagrees with its embedded stream`);
|
|
2129
|
+
if (!isDeepStrictEqual(event.data.usage, assembler.usage)) throw new Error(`seed assistant/message at index ${index} usage disagrees with its embedded stream`);
|
|
2130
|
+
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`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
function decodeCurrentGeneration(bytes, compression) {
|
|
2134
|
+
if (compression === "none") {
|
|
2135
|
+
const headerEnd = bytes.indexOf(10);
|
|
2136
|
+
if (headerEnd === -1) throw new Error("empty or header-less session log");
|
|
2137
|
+
const scanner = new SessionLogScanner(bytes.subarray(0, headerEnd + 1), "strict");
|
|
2138
|
+
scanner.write(bytes.subarray(headerEnd + 1));
|
|
2139
|
+
return finishCurrentGenerationScan(scanner);
|
|
2140
|
+
}
|
|
2141
|
+
const { frames, tornStart } = scanZstdFrames(bytes);
|
|
2142
|
+
if (frames.length === 0) throw new Error("empty or header-less Zstandard session log");
|
|
2143
|
+
if (tornStart !== void 0) throw new Error("current session generation has a torn physical tail");
|
|
2144
|
+
const decoder = createZstdFrameDecoder();
|
|
2145
|
+
try {
|
|
2146
|
+
const plaintext = decoder.decode(bytes, frames);
|
|
2147
|
+
const header = plaintext.next();
|
|
2148
|
+
/* v8 ignore next -- a non-empty structural frame list yields once or throws. */
|
|
2149
|
+
if (header.done) throw new Error("empty or header-less Zstandard session log");
|
|
2150
|
+
assertIndependentHeaderFrame(header.value);
|
|
2151
|
+
const scanner = new SessionLogScanner(header.value, "strict");
|
|
2152
|
+
for (const chunk of plaintext) scanner.write(chunk);
|
|
2153
|
+
return finishCurrentGenerationScan(scanner);
|
|
2154
|
+
} finally {
|
|
2155
|
+
decoder.close();
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
function finishCurrentGenerationScan(scanner) {
|
|
2159
|
+
const inputBytes = scanner.checkpoint().inputBytes;
|
|
2160
|
+
const decoded = scanner.finish();
|
|
2161
|
+
if (decoded.committedBytes !== inputBytes) throw new Error("current session generation has a torn physical tail");
|
|
2162
|
+
return decoded;
|
|
2163
|
+
}
|
|
2164
|
+
function stringifyJson(value, subject) {
|
|
2165
|
+
let text;
|
|
2166
|
+
try {
|
|
2167
|
+
text = JSON.stringify(value);
|
|
2168
|
+
} catch (error) {
|
|
2169
|
+
throw new Error(`${subject} is not lossless JSON`, { cause: error });
|
|
2170
|
+
}
|
|
2171
|
+
if (typeof text !== "string") throw new Error(`${subject} is not lossless JSON`);
|
|
2172
|
+
return text;
|
|
2173
|
+
}
|
|
2174
|
+
function assertIndependentHeaderFrame(plaintext) {
|
|
2175
|
+
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");
|
|
2176
|
+
}
|
|
2177
|
+
function assertGenerationPaths(sourcePath, sourceVersion, currentPath, currentVersion, compression) {
|
|
2178
|
+
const expectedSource = generationLogFilename(sourceVersion, compression);
|
|
2179
|
+
const expectedCurrent = generationLogFilename(currentVersion, compression);
|
|
2180
|
+
if (basename(sourcePath) !== expectedSource) throw new Error(`resolved JSONL source path must end with "${expectedSource}": ${sourcePath}`);
|
|
2181
|
+
if (basename(currentPath) !== expectedCurrent) throw new Error(`current JSONL generation path must end with "${expectedCurrent}": ${currentPath}`);
|
|
2182
|
+
if (dirname(sourcePath) !== dirname(currentPath)) throw new Error("source and current JSONL generations must share one Session directory");
|
|
2183
|
+
return logSuffix(compression);
|
|
2184
|
+
}
|
|
2185
|
+
async function syncDirectory(path, internals) {
|
|
2186
|
+
/* v8 ignore next -- Windows namespace operations request write-through directly. */
|
|
2187
|
+
if (internals.platform === "win32") return;
|
|
2188
|
+
const handle = await internals.fs.open(path, "r");
|
|
2189
|
+
try {
|
|
2190
|
+
await handle.sync();
|
|
2191
|
+
} finally {
|
|
2192
|
+
await handle.close();
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
/** Produce bounded JSONL chunks while yielding between main-thread encoding slices. */
|
|
2196
|
+
async function* encodeMigrationRows(artifact, format, signal) {
|
|
2197
|
+
signal?.throwIfAborted();
|
|
2198
|
+
let lines = [];
|
|
2199
|
+
let bytes = 0;
|
|
2200
|
+
for (const value of artifact.events) {
|
|
2201
|
+
const line = `${stringifyJson(format.encodeEvent(value), `migrated Session event ${value.seq}`)}\n`;
|
|
2202
|
+
const lineBytes = Buffer.byteLength(line);
|
|
2203
|
+
if (bytes > 0 && bytes + lineBytes > MIGRATION_WORK_CHUNK_BYTES) {
|
|
2204
|
+
yield Buffer.from(lines.join(""));
|
|
2205
|
+
await scheduler.yield();
|
|
2206
|
+
signal?.throwIfAborted();
|
|
2207
|
+
lines = [];
|
|
2208
|
+
bytes = 0;
|
|
2209
|
+
}
|
|
2210
|
+
lines.push(line);
|
|
2211
|
+
bytes += lineBytes;
|
|
2212
|
+
}
|
|
2213
|
+
yield Buffer.from(lines.join(""));
|
|
2214
|
+
}
|
|
2215
|
+
async function writeMigrationChunks(chunks, write) {
|
|
2216
|
+
let pending = [];
|
|
2217
|
+
let bytes = 0;
|
|
2218
|
+
for await (const chunk of chunks) {
|
|
2219
|
+
pending.push(chunk);
|
|
2220
|
+
bytes += chunk.length;
|
|
2221
|
+
if (bytes < MIGRATION_WRITE_CHUNK_BYTES) continue;
|
|
2222
|
+
await write(pending.length === 1 ? pending[0] : Buffer.concat(pending, bytes));
|
|
2223
|
+
pending = [];
|
|
2224
|
+
bytes = 0;
|
|
2225
|
+
}
|
|
2226
|
+
if (bytes > 0) await write(pending.length === 1 ? pending[0] : Buffer.concat(pending, bytes));
|
|
2227
|
+
}
|
|
2228
|
+
/** Encode directly into one synced stage without a whole-artifact row or byte buffer. */
|
|
2229
|
+
async function writeSyncedTemp(currentPath, suffix, compression, artifact, format, signal, internals) {
|
|
2230
|
+
signal?.throwIfAborted();
|
|
2231
|
+
let path;
|
|
2232
|
+
let handle;
|
|
2233
|
+
for (;;) {
|
|
2234
|
+
path = join(dirname(currentPath), `session.migration.${internals.randomToken()}${suffix}.tmp`);
|
|
2235
|
+
try {
|
|
2236
|
+
handle = await internals.fs.open(path, "wx", 384);
|
|
2237
|
+
break;
|
|
2238
|
+
} catch (error) {
|
|
2239
|
+
if (isEEXIST(error)) continue;
|
|
2240
|
+
throw error;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
const hash = createHash("sha256");
|
|
2244
|
+
let bytes = 0;
|
|
2245
|
+
const write = async (chunk) => {
|
|
2246
|
+
await handle.writeFile(chunk);
|
|
2247
|
+
hash.update(chunk);
|
|
2248
|
+
bytes += chunk.length;
|
|
2249
|
+
};
|
|
2250
|
+
let failure;
|
|
2251
|
+
try {
|
|
2252
|
+
const headerValue = format.encodeHeader(artifact.header, artifact.inheritedEventCount);
|
|
2253
|
+
const header = Buffer.from(`${stringifyJson(headerValue, "migrated session header")}\n`);
|
|
2254
|
+
await write(compression === "zstd" ? await compressZstdFrame(header) : header);
|
|
2255
|
+
if (artifact.events.length > 0) {
|
|
2256
|
+
const rows = encodeMigrationRows(artifact, format, signal);
|
|
2257
|
+
if (compression === "none") await writeMigrationChunks(rows, write);
|
|
2258
|
+
else await new Promise((resolve, reject) => {
|
|
2259
|
+
pipeline(Readable.from(rows, {
|
|
2260
|
+
objectMode: false,
|
|
2261
|
+
highWaterMark: MIGRATION_WORK_CHUNK_BYTES
|
|
2262
|
+
}), createZstdCompress(ZSTD_CHECKSUM_OPTIONS), async (source) => {
|
|
2263
|
+
await writeMigrationChunks(source, write);
|
|
2264
|
+
}, (error) => {
|
|
2265
|
+
if (error instanceof Error) reject(error);
|
|
2266
|
+
else resolve();
|
|
2267
|
+
});
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
signal?.throwIfAborted();
|
|
2271
|
+
await handle.sync();
|
|
2272
|
+
} catch (error) {
|
|
2273
|
+
failure = error;
|
|
2274
|
+
}
|
|
2275
|
+
try {
|
|
2276
|
+
await handle.close();
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
failure = failure === void 0 ? error : new AggregateError([failure, error], `failed to write and close migration stage "${path}"`);
|
|
2279
|
+
}
|
|
2280
|
+
if (failure !== void 0) {
|
|
2281
|
+
const writeError = failure instanceof Error ? failure : new Error("migration stage write failed with a non-Error rejection", { cause: failure });
|
|
2282
|
+
await removeTemporary(path, writeError, internals);
|
|
2283
|
+
throw writeError;
|
|
2284
|
+
}
|
|
2285
|
+
return {
|
|
2286
|
+
path,
|
|
2287
|
+
bytes,
|
|
2288
|
+
digest: hash.digest("hex")
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
/** Remove one temporary file without hiding the operation failure that made it disposable. */
|
|
2292
|
+
async function removeTemporary(path, primaryFailure, internals) {
|
|
2293
|
+
try {
|
|
2294
|
+
await internals.fs.rm(path);
|
|
2295
|
+
} catch (cleanupFailure) {
|
|
2296
|
+
throw new AggregateError([primaryFailure, cleanupFailure], `failed to clean migration temporary "${path}" after an earlier failure`);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
/** Remove a redundant stage after the target has been validated as committed. */
|
|
2300
|
+
async function removeCommittedTemporary(path, internals) {
|
|
2301
|
+
try {
|
|
2302
|
+
await internals.fs.rm(path);
|
|
2303
|
+
} catch {}
|
|
2304
|
+
}
|
|
2305
|
+
async function publishCurrentExclusive(staged, currentPath, internals) {
|
|
2306
|
+
if (internals.platform === "win32") try {
|
|
2307
|
+
await internals.publishNewWin32(staged, currentPath);
|
|
2308
|
+
return true;
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
/* v8 ignore else -- native helper tests own non-collision Win32 failures. */
|
|
2311
|
+
if (isEEXIST(error)) return false;
|
|
2312
|
+
/* v8 ignore next -- the filesystem error is already complete. */
|
|
2313
|
+
throw error;
|
|
2314
|
+
}
|
|
2315
|
+
try {
|
|
2316
|
+
await internals.fs.link(staged, currentPath);
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
/* v8 ignore else -- a non-collision filesystem error propagates unchanged. */
|
|
2319
|
+
if (isEEXIST(error)) return false;
|
|
2320
|
+
/* v8 ignore next -- the filesystem error is already complete. */
|
|
2321
|
+
throw error;
|
|
2322
|
+
}
|
|
2323
|
+
await syncDirectory(dirname(currentPath), internals);
|
|
2324
|
+
return true;
|
|
2325
|
+
}
|
|
2326
|
+
function asError(error) {
|
|
2327
|
+
return error instanceof Error ? error : new Error("current-generation validation failed with a non-Error rejection", { cause: error });
|
|
2328
|
+
}
|
|
2329
|
+
async function inspectExpectedCurrent(currentPath, internals, inspect) {
|
|
2330
|
+
try {
|
|
2331
|
+
const expectedName = basename(currentPath);
|
|
2332
|
+
const names = await internals.fs.readdir(dirname(currentPath));
|
|
2333
|
+
if (!names.includes(expectedName)) {
|
|
2334
|
+
const noncanonical = names.find((name) => name.toLowerCase() === expectedName.toLowerCase());
|
|
2335
|
+
if (noncanonical !== void 0) throw new Error(`target resolves to noncanonical directory entry "${noncanonical}"`);
|
|
2336
|
+
}
|
|
2337
|
+
const info = await internals.fs.lstat(currentPath);
|
|
2338
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`target is a ${info.isSymbolicLink() ? "symbolic link" : "non-regular file"}`);
|
|
2339
|
+
return await inspect();
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
if (isErrnoException$1(error)) throw error;
|
|
2342
|
+
throw new JsonlGenerationTargetConflictError(currentPath, asError(error));
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
function withOverrides(overrides) {
|
|
2346
|
+
return {
|
|
2347
|
+
...defaultInternals,
|
|
2348
|
+
...overrides,
|
|
2349
|
+
fs: {
|
|
2350
|
+
...defaultFileSystem,
|
|
2351
|
+
...overrides.fs
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
async function publishPreparedMigration(options, suffix, artifact, sourceIdentity, internals) {
|
|
2356
|
+
await scheduler.yield();
|
|
2357
|
+
const { sourcePath, currentPath, compression, verifyCurrentFile } = options;
|
|
2358
|
+
const eventCount = artifact.events.length;
|
|
2359
|
+
let staged = await writeSyncedTemp(currentPath, suffix, compression, artifact, options.format, void 0, internals);
|
|
2360
|
+
try {
|
|
2361
|
+
const verifiedStage = await verifyCurrentFile(staged.path, compression, artifact.header.id, eventCount);
|
|
2362
|
+
if (verifiedStage.bytes !== staged.bytes || verifiedStage.digest !== staged.digest) throw new Error("staged session generation changed during verification");
|
|
2363
|
+
await internals.barrier("before-source-check", 1);
|
|
2364
|
+
if (identity(await internals.fs.stat(sourcePath)) !== identity(sourceIdentity)) throw new JsonlGenerationSourceChangedError(sourcePath);
|
|
2365
|
+
const published = await publishCurrentExclusive(staged.path, currentPath, internals);
|
|
2366
|
+
if (published && internals.platform === "win32") staged = {
|
|
2367
|
+
...staged,
|
|
2368
|
+
path: ""
|
|
2369
|
+
};
|
|
2370
|
+
await internals.barrier("after-publication", 1);
|
|
2371
|
+
let currentIdentity;
|
|
2372
|
+
if (published) {
|
|
2373
|
+
if (staged.path !== "") {
|
|
2374
|
+
await removeCommittedTemporary(staged.path, internals);
|
|
2375
|
+
staged = {
|
|
2376
|
+
...staged,
|
|
2377
|
+
path: ""
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
currentIdentity = await internals.fs.stat(currentPath);
|
|
2381
|
+
} else {
|
|
2382
|
+
currentIdentity = (await inspectExpectedCurrent(currentPath, internals, async () => {
|
|
2383
|
+
const candidate = await verifyCurrentFile(currentPath, compression, artifact.header.id, eventCount, staged);
|
|
2384
|
+
if (candidate.bytes !== staged.bytes || candidate.digest !== staged.digest) throw new Error("target bytes differ from the migrated generation");
|
|
2385
|
+
return candidate;
|
|
2386
|
+
})).identity;
|
|
2387
|
+
await removeCommittedTemporary(staged.path, internals);
|
|
2388
|
+
staged = {
|
|
2389
|
+
...staged,
|
|
2390
|
+
path: ""
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
return currentIdentity;
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
if (staged.path !== "") await removeTemporary(staged.path, error, internals);
|
|
2396
|
+
throw error;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
async function prepareMigration(options, internals) {
|
|
2400
|
+
const { sourcePath, sourceVersion, currentPath, compression, format, signal } = options;
|
|
2401
|
+
const suffix = assertGenerationPaths(sourcePath, sourceVersion, currentPath, format.currentVersion, compression);
|
|
2402
|
+
if (sourceVersion >= format.currentVersion) throw new Error(`migration preparation requires a historical source, got v${sourceVersion}`);
|
|
2403
|
+
const source = await readStableSnapshot(sourcePath, signal, internals.fs);
|
|
2404
|
+
let artifact;
|
|
2405
|
+
try {
|
|
2406
|
+
artifact = await decodeStreamingMigration(source.bytes, compression, sourceVersion, format, options.validateHistoricalHeader, signal);
|
|
2407
|
+
} catch (error) {
|
|
2408
|
+
if (format.isUnsupportedMigrationError?.(error) === true) throw new JsonlGenerationUnsupportedMigrationError(sourceVersion, error);
|
|
2409
|
+
throw error;
|
|
2410
|
+
}
|
|
2411
|
+
if (artifact.header.version !== format.currentVersion) throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`);
|
|
2412
|
+
const sourceIdentity = source.identity;
|
|
2413
|
+
let publication;
|
|
2414
|
+
return {
|
|
2415
|
+
sourceIdentity,
|
|
2416
|
+
artifact,
|
|
2417
|
+
publish() {
|
|
2418
|
+
if (publication === void 0) publication = publishPreparedMigration(options, suffix, artifact, sourceIdentity, internals);
|
|
2419
|
+
return publication;
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Decode and migrate one historical generation without writing its successor.
|
|
2425
|
+
* @param options - resolved source, current target, format adapter, and load cancellation.
|
|
2426
|
+
* @returns the current artifact and an idempotent explicit publication operation.
|
|
2427
|
+
*/
|
|
2428
|
+
function prepareJsonlMigration(options) {
|
|
2429
|
+
return defaultGenerationRuntime.prepare(options);
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Create one generation runtime with fixed filesystem and publication dependencies.
|
|
2433
|
+
* @param overrides - deterministic filesystem, platform, and race dependencies.
|
|
2434
|
+
* @returns bound generation operations.
|
|
2435
|
+
*/
|
|
2436
|
+
function createJsonlGenerationRuntime(overrides = {}) {
|
|
2437
|
+
const internals = withOverrides(overrides);
|
|
2438
|
+
return {
|
|
2439
|
+
readStable: (path, signal) => readStableSnapshot(path, signal, internals.fs),
|
|
2440
|
+
prepare: (options) => prepareMigration(options, internals),
|
|
2441
|
+
verify: (path, compression, expectedId, expectedEventCount, expectedPrefix) => verifyCurrentGeneration(path, compression, expectedId, expectedEventCount, internals.fs, expectedPrefix)
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
const defaultGenerationRuntime = createJsonlGenerationRuntime();
|
|
2445
|
+
//#endregion
|
|
2446
|
+
//#region lib/types/index.js
|
|
2447
|
+
/**
|
|
2448
|
+
* JSONL durable session-persistence backend. It stores a header and contiguous
|
|
2449
|
+
* events in immutable historical generation files under one directory per session.
|
|
2450
|
+
* Normal writes append to the current generation; explicit destructive deletion
|
|
2451
|
+
* rewrites that current generation's retained prefix. It serves the handle-based
|
|
2452
|
+
* `SessionPersistence` API: `create`/`open` return per-session handles, and
|
|
2453
|
+
* every read validates the same fail-closed storage contract.
|
|
2454
|
+
* @module @x1a0f3n9/dsh-session-persistence-jsonl
|
|
2455
|
+
*/
|
|
2456
|
+
/**
|
|
2457
|
+
* Internal handoff-reuse policy, not deployment configuration: a cold
|
|
2458
|
+
* observation and the resume that immediately follows it reuse one parsed
|
|
2459
|
+
* log, so the memo only needs the sessions in flight between those steps.
|
|
2460
|
+
*/
|
|
2461
|
+
const COLD_LOG_MEMO_MAX_ENTRIES = 2;
|
|
2462
|
+
const DEFAULT_COMPRESSION = "zstd";
|
|
2463
|
+
/**
|
|
2464
|
+
* Internal scheduling constant, not deployment configuration: balance
|
|
2465
|
+
* frame-boundary event-loop yields against `setImmediate` overhead. One frame
|
|
2466
|
+
* remains an indivisible synchronous decode.
|
|
2467
|
+
*/
|
|
2468
|
+
const ZSTD_DECODE_YIELD_INTERVAL_MS = 500;
|
|
2469
|
+
/** Assert that the independently decodable first frame contains only the header record. */
|
|
2470
|
+
function assertZstdHeaderFrame(plaintext) {
|
|
2471
|
+
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");
|
|
2472
|
+
}
|
|
2473
|
+
/** Loader schema for the JSONL artifact's physical encoding. */
|
|
2474
|
+
const JsonlCompressionSchema = z.union([z.const("zstd"), z.const("none")]).default(DEFAULT_COMPRESSION);
|
|
2475
|
+
/** Deep-freeze one acyclic stored JSON event without recursive calls. */
|
|
2476
|
+
function freezeStoredEvent(event) {
|
|
2477
|
+
const pending = [event];
|
|
2478
|
+
while (pending.length > 0) {
|
|
2479
|
+
const current = pending.pop();
|
|
2480
|
+
Object.freeze(current);
|
|
2481
|
+
for (const key in current) {
|
|
2482
|
+
const child = current[key];
|
|
2483
|
+
if (child !== null && typeof child === "object") pending.push(child);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
/** Establish immutable sharing for one decoded event graph and report that state. */
|
|
2488
|
+
function freezeStoredEvents(events) {
|
|
2489
|
+
for (const event of events) freezeStoredEvent(event);
|
|
2490
|
+
Object.freeze(events);
|
|
2491
|
+
return {
|
|
2492
|
+
eventState: "shared-frozen",
|
|
2493
|
+
events
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
/** Build the stat-derived best-effort change token shared by full and lightweight reads. */
|
|
2497
|
+
function fileRevision(identity) {
|
|
2498
|
+
return SessionPersistenceRevision([
|
|
2499
|
+
identity.dev,
|
|
2500
|
+
identity.ino,
|
|
2501
|
+
identity.size,
|
|
2502
|
+
identity.mtimeNs,
|
|
2503
|
+
identity.ctimeNs
|
|
2504
|
+
].join(":"));
|
|
2505
|
+
}
|
|
2506
|
+
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
|
2507
|
+
function isENOENT(error) {
|
|
2508
|
+
return error?.code === "ENOENT";
|
|
2509
|
+
}
|
|
2510
|
+
/** Whether a filesystem-owned failure should retain its original errno and path. */
|
|
2511
|
+
function isErrnoException(error) {
|
|
2512
|
+
return typeof error?.code === "string";
|
|
2513
|
+
}
|
|
2514
|
+
/** Preserve an Error abort reason and normalize hostile non-Error reasons. */
|
|
2515
|
+
function abortError(signal) {
|
|
2516
|
+
return signal.reason instanceof Error ? signal.reason : new Error("session migration preparation aborted", { cause: signal.reason });
|
|
2517
|
+
}
|
|
2518
|
+
/** Let one caller stop waiting without transferring cancellation ownership to shared work. */
|
|
2519
|
+
function waitWithAbort(operation, signal) {
|
|
2520
|
+
if (signal === void 0) return operation;
|
|
2521
|
+
/* v8 ignore next -- requireStoredLog synchronously rechecks the signal immediately before waiting. */
|
|
2522
|
+
if (signal.aborted) return Promise.reject(abortError(signal));
|
|
2523
|
+
return new Promise((resolve, reject) => {
|
|
2524
|
+
const stopWaiting = () => {
|
|
2525
|
+
reject(abortError(signal));
|
|
2526
|
+
};
|
|
2527
|
+
signal.addEventListener("abort", stopWaiting, { once: true });
|
|
2528
|
+
operation.then((value) => {
|
|
2529
|
+
signal.removeEventListener("abort", stopWaiting);
|
|
2530
|
+
resolve(value);
|
|
2531
|
+
}, (error) => {
|
|
2532
|
+
signal.removeEventListener("abort", stopWaiting);
|
|
2533
|
+
/* v8 ignore else -- the preparation owner normalizes every rejection before this waiter sees it. */
|
|
2534
|
+
if (error instanceof Error) reject(error);
|
|
2535
|
+
else reject(new Error("session migration preparation failed", { cause: error }));
|
|
2536
|
+
});
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* The JSONL persistence backend. Load as a plugin; it registers as
|
|
2541
|
+
* `ctx.sessionPersistence`. Sessions materialize lazily: a created session is
|
|
2542
|
+
* visible to this process immediately, reaches disk on its first append or
|
|
2543
|
+
* flush, and never existed if the process crashes before that.
|
|
2544
|
+
*/
|
|
2545
|
+
var JsonlSessionPersistence = class extends SessionPersistence {
|
|
2546
|
+
config;
|
|
2547
|
+
static Config = z.object({
|
|
2548
|
+
root: z.string().required(),
|
|
2549
|
+
compression: JsonlCompressionSchema
|
|
2550
|
+
});
|
|
2551
|
+
/** Backend label for diagnostics and effects; shadows `Service.name` without changing the service key. */
|
|
2552
|
+
name = "session-persistence-jsonl";
|
|
2553
|
+
root;
|
|
2554
|
+
compression;
|
|
2555
|
+
rootEncodingCheck;
|
|
2556
|
+
tracker = new JsonlBackendTracker(this.name);
|
|
2557
|
+
generationFormat;
|
|
2558
|
+
/**
|
|
2559
|
+
* Bounded LRU of parsed, validated stored logs keyed by session id and
|
|
2560
|
+
* guarded by the stat-derived revision, so an immediate cold-read handoff
|
|
2561
|
+
* (observation then resume) parses the artifact once. Every local mutation
|
|
2562
|
+
* for an id invalidates its entry; a foreign write misses through the
|
|
2563
|
+
* revision guard.
|
|
2564
|
+
*/
|
|
2565
|
+
coldLogMemo = /* @__PURE__ */ new Map();
|
|
2566
|
+
/** One joinable decode/migration operation per selected historical Session file revision. */
|
|
2567
|
+
migrationPreparations = /* @__PURE__ */ new Map();
|
|
2568
|
+
constructor(ctx, config) {
|
|
2569
|
+
super(ctx);
|
|
2570
|
+
this.config = config;
|
|
2571
|
+
/* v8 ignore next 5 -- generated catalog and Session source share one build-time version owner. */
|
|
2572
|
+
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}`);
|
|
2573
|
+
this.root = resolve(config.root);
|
|
2574
|
+
this.compression = config.compression ?? DEFAULT_COMPRESSION;
|
|
2575
|
+
this.generationFormat = {
|
|
2576
|
+
currentVersion: sessionFormatCatalog.currentVersion,
|
|
2577
|
+
createRestore: (header) => sessionFormatCatalog.createRestore(header, {
|
|
2578
|
+
recovery: "recoverable",
|
|
2579
|
+
validation: "transformed"
|
|
2580
|
+
}),
|
|
2581
|
+
encodeHeader: (header, inheritedEventCount) => sessionFormatCatalog.encodeCurrentHeader(header, inheritedEventCount),
|
|
2582
|
+
encodeEvent: (event) => sessionFormatCatalog.encodeCurrentEvent(event),
|
|
2583
|
+
isUnsupportedMigrationError: (error) => error instanceof SessionFormatUnsupportedMigrationError
|
|
2584
|
+
};
|
|
2585
|
+
this.assertUsableRoot();
|
|
2586
|
+
this.tracker.install(ctx);
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Refusal-diagnostics hook: the absolute target path, without touching the filesystem.
|
|
2590
|
+
* @param meta - the stored header naming the session and its cwd.
|
|
2591
|
+
* @returns the artifact kind and absolute path.
|
|
2592
|
+
*/
|
|
2593
|
+
locate(meta) {
|
|
2594
|
+
return {
|
|
2595
|
+
kind: "jsonl",
|
|
2596
|
+
path: logPath(this.root, meta.cwd, meta.id, this.compression)
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Create a new stored session and take its write ownership. The session is
|
|
2601
|
+
* visible to this process immediately; the physical artifact appears on the
|
|
2602
|
+
* first append or flush.
|
|
2603
|
+
* @param header - the immutable header to store; must be losslessly
|
|
2604
|
+
* JSON-serializable with a non-negative safe-integer `createdAt`.
|
|
2605
|
+
* @param options - optional cancellation.
|
|
2606
|
+
* @returns the owned write handle.
|
|
2607
|
+
*/
|
|
2608
|
+
async create(header, options) {
|
|
2609
|
+
options?.signal?.throwIfAborted();
|
|
2610
|
+
const snapshot = materializeCreateHeader(header);
|
|
2611
|
+
toHeaderLine(snapshot, options?.inheritedEventCount);
|
|
2612
|
+
const inheritedEventCount = SessionLogOffset(options?.inheritedEventCount ?? 0);
|
|
2613
|
+
await this.ensureRootEncoding();
|
|
2614
|
+
options?.signal?.throwIfAborted();
|
|
2615
|
+
if (this.tracker.hasPending(snapshot.id) || await this.findLog(snapshot.id, options?.signal) !== void 0) throw new SessionAlreadyExistsError(snapshot.id);
|
|
2616
|
+
options?.signal?.throwIfAborted();
|
|
2617
|
+
this.tracker.registerCreated(snapshot, inheritedEventCount);
|
|
2618
|
+
return this.tracker.adopt(new JsonlSessionHandle(this, snapshot.id, snapshot, "write", {
|
|
2619
|
+
cursor: 0,
|
|
2620
|
+
materialized: false,
|
|
2621
|
+
inheritedEventCount
|
|
2622
|
+
}));
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Open an existing stored session for `read` or single-writer `write`.
|
|
2626
|
+
* @param id - the stored session to open.
|
|
2627
|
+
* @param access - `read` (no ownership) or `write` (atomic in-process claim).
|
|
2628
|
+
* @param options - optional cancellation.
|
|
2629
|
+
* @returns the open handle.
|
|
2630
|
+
*/
|
|
2631
|
+
async open(id, access, options) {
|
|
2632
|
+
options?.signal?.throwIfAborted();
|
|
2633
|
+
await this.ensureRootEncoding();
|
|
2634
|
+
options?.signal?.throwIfAborted();
|
|
2635
|
+
const pending = this.tracker.pendingOf(id);
|
|
2636
|
+
if (access === "read") {
|
|
2637
|
+
if (pending !== void 0) return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, "read", {
|
|
2638
|
+
cursor: 0,
|
|
2639
|
+
materialized: false,
|
|
2640
|
+
inheritedEventCount: pending.inheritedEventCount
|
|
2641
|
+
}));
|
|
2642
|
+
const stored = await this.requireStoredLog(id, options?.signal);
|
|
2643
|
+
let state;
|
|
2644
|
+
if (stored.status === "prepared") state = {
|
|
2645
|
+
cursor: 0,
|
|
2646
|
+
materialized: true,
|
|
2647
|
+
inheritedEventCount: stored.inheritedEventCount,
|
|
2648
|
+
primed: stored
|
|
2649
|
+
};
|
|
2650
|
+
else state = {
|
|
2651
|
+
cursor: 0,
|
|
2652
|
+
materialized: true,
|
|
2653
|
+
inheritedEventCount: stored.inheritedEventCount
|
|
2654
|
+
};
|
|
2655
|
+
return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, "read", state));
|
|
2656
|
+
}
|
|
2657
|
+
this.tracker.claimWrite(id);
|
|
2658
|
+
let lease;
|
|
2659
|
+
try {
|
|
2660
|
+
const resolved = await this.findLog(id, options?.signal);
|
|
2661
|
+
if (resolved === void 0) throw new SessionPersistenceNotFoundError(id);
|
|
2662
|
+
lease = await this.acquireLease(id, void 0, dirname(resolved.currentPath));
|
|
2663
|
+
const prepared = await this.requireStoredLog(id, options?.signal);
|
|
2664
|
+
options?.signal?.throwIfAborted();
|
|
2665
|
+
let stored;
|
|
2666
|
+
if (prepared.status === "prepared") stored = await this.publishStoredMigration(id, prepared);
|
|
2667
|
+
else stored = prepared;
|
|
2668
|
+
options?.signal?.throwIfAborted();
|
|
2669
|
+
return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, "write", {
|
|
2670
|
+
cursor: stored.events.length,
|
|
2671
|
+
materialized: true,
|
|
2672
|
+
tornTruncateTo: stored.tornTruncateTo,
|
|
2673
|
+
recoveredTail: stored.recoveredTail,
|
|
2674
|
+
inheritedEventCount: stored.inheritedEventCount,
|
|
2675
|
+
primed: stored
|
|
2676
|
+
}, lease));
|
|
2677
|
+
} catch (error) {
|
|
2678
|
+
/* v8 ignore next -- typed backends and fs reject with Error */
|
|
2679
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
2680
|
+
let releaseFailure;
|
|
2681
|
+
try {
|
|
2682
|
+
await lease?.release();
|
|
2683
|
+
} catch (raw) {
|
|
2684
|
+
/* v8 ignore next -- lock releases reject with Error */
|
|
2685
|
+
releaseFailure = raw instanceof Error ? raw : new Error(String(raw));
|
|
2686
|
+
}
|
|
2687
|
+
this.tracker.releaseClaim(id);
|
|
2688
|
+
if (releaseFailure !== void 0) throw new AggregateError([failure, releaseFailure], `session "${id}": write open failed and its lock release failed`);
|
|
2689
|
+
throw failure;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
2693
|
+
* Flush every active write handle in one durability barrier; see the seam
|
|
2694
|
+
* contract.
|
|
2695
|
+
* @returns resolution once every write handle active at the call has flushed.
|
|
2696
|
+
*/
|
|
2697
|
+
flush() {
|
|
2698
|
+
return this.tracker.flushAll();
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2701
|
+
* Read a tail page of one current-generation JSONL log without restoring
|
|
2702
|
+
* the whole artifact. Historical generations return `undefined` so the
|
|
2703
|
+
* caller can migrate through a full observation.
|
|
2704
|
+
* @param id - the stored session to read.
|
|
2705
|
+
* @param options - page bounds and cancellation.
|
|
2706
|
+
* @returns the covering suffix, or `undefined` when the selected generation
|
|
2707
|
+
* is older than this build.
|
|
2708
|
+
*/
|
|
2709
|
+
async readHistorySuffix(id, options) {
|
|
2710
|
+
options.signal?.throwIfAborted();
|
|
2711
|
+
await this.ensureRootEncoding();
|
|
2712
|
+
options.signal?.throwIfAborted();
|
|
2713
|
+
const pending = this.tracker.pendingOf(id);
|
|
2714
|
+
if (pending !== void 0) return {
|
|
2715
|
+
header: pending.header,
|
|
2716
|
+
inheritedEventCount: pending.inheritedEventCount,
|
|
2717
|
+
events: [],
|
|
2718
|
+
cursor: -1
|
|
2719
|
+
};
|
|
2720
|
+
const selected = await this.findLog(id, options.signal);
|
|
2721
|
+
if (selected === void 0) throw new SessionPersistenceNotFoundError(id);
|
|
2722
|
+
if (selected.sourceVersion < SESSION_FORMAT_VERSION) return void 0;
|
|
2723
|
+
if (selected.sourceVersion > SESSION_FORMAT_VERSION) throw new SessionFormatUnsupportedError(`${sessionFormatVersionRefusal(id, selected.sourceVersion)} (raw log: ${selected.sourcePath})`, {
|
|
2724
|
+
kind: "jsonl",
|
|
2725
|
+
path: selected.sourcePath
|
|
2726
|
+
});
|
|
2727
|
+
return readJsonlHistorySuffix((await readStableJsonlFile(selected.sourcePath, options.signal)).bytes, this.compression, options);
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Observe one stored session without reading its event log.
|
|
2731
|
+
* @param id - the stored session to observe.
|
|
2732
|
+
* @param options - optional cancellation.
|
|
2733
|
+
* @returns the snapshot (`sizeBytes` carries the physical artifact size), or
|
|
2734
|
+
* `undefined` when the session does not exist.
|
|
2735
|
+
*/
|
|
2736
|
+
async stat(id, options) {
|
|
2737
|
+
options?.signal?.throwIfAborted();
|
|
2738
|
+
await this.ensureRootEncoding();
|
|
2739
|
+
options?.signal?.throwIfAborted();
|
|
2740
|
+
const pending = this.tracker.pendingOf(id);
|
|
2741
|
+
if (pending !== void 0) return {
|
|
2742
|
+
header: pending.header,
|
|
2743
|
+
revision: pending.revision
|
|
2744
|
+
};
|
|
2745
|
+
const selected = await this.findLog(id, options?.signal);
|
|
2746
|
+
if (selected === void 0) return void 0;
|
|
2747
|
+
const header = await this.readGenerationHeader(selected, id, options?.signal);
|
|
2748
|
+
if (header === void 0) return void 0;
|
|
2749
|
+
try {
|
|
2750
|
+
const identity = await stat(selected.sourcePath, { bigint: true });
|
|
2751
|
+
options?.signal?.throwIfAborted();
|
|
2752
|
+
return {
|
|
2753
|
+
header,
|
|
2754
|
+
revision: fileRevision(identity),
|
|
2755
|
+
sizeBytes: Number(identity.size)
|
|
2756
|
+
};
|
|
2757
|
+
} catch (error) {
|
|
2758
|
+
options?.signal?.throwIfAborted();
|
|
2759
|
+
if (isENOENT(error)) return void 0;
|
|
2760
|
+
throw error;
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* List every stored session visible to this process: materialized artifacts
|
|
2765
|
+
* plus this process's created-but-unmaterialized sessions.
|
|
2766
|
+
* @param options - optional cancellation.
|
|
2767
|
+
* @returns one snapshot per session, in no promised order.
|
|
2768
|
+
*/
|
|
2769
|
+
async list(options) {
|
|
2770
|
+
const signal = options?.signal;
|
|
2771
|
+
const snapshots = [];
|
|
2772
|
+
const listed = /* @__PURE__ */ new Set();
|
|
2773
|
+
const pending = [...this.tracker.pendingEntries()];
|
|
2774
|
+
for (const artifact of await this.listArtifacts(signal)) {
|
|
2775
|
+
signal?.throwIfAborted();
|
|
2776
|
+
try {
|
|
2777
|
+
const identity = await stat(artifact.path, { bigint: true });
|
|
2778
|
+
signal?.throwIfAborted();
|
|
2779
|
+
listed.add(artifact.header.id);
|
|
2780
|
+
snapshots.push({
|
|
2781
|
+
header: artifact.header,
|
|
2782
|
+
revision: fileRevision(identity),
|
|
2783
|
+
sizeBytes: Number(identity.size)
|
|
2784
|
+
});
|
|
2785
|
+
} catch (error) {
|
|
2786
|
+
signal?.throwIfAborted();
|
|
2787
|
+
if (!isENOENT(error)) throw error;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
for (const [id, entry] of pending) if (!listed.has(id)) snapshots.push({
|
|
2791
|
+
header: entry.header,
|
|
2792
|
+
revision: entry.revision
|
|
2793
|
+
});
|
|
2794
|
+
signal?.throwIfAborted();
|
|
2795
|
+
return snapshots;
|
|
2796
|
+
}
|
|
2797
|
+
/** Resolve and read one stored log, refusing loudly when the artifact is absent. */
|
|
2798
|
+
async requireStoredLog(id, signal) {
|
|
2799
|
+
const selected = await this.findLog(id, signal);
|
|
2800
|
+
if (selected === void 0) throw new SessionPersistenceNotFoundError(id);
|
|
2801
|
+
if (selected.sourceVersion < SESSION_FORMAT_VERSION) {
|
|
2802
|
+
const sourceRevision = fileRevision(await stat(selected.sourcePath, { bigint: true }));
|
|
2803
|
+
signal?.throwIfAborted();
|
|
2804
|
+
let preparation = this.migrationPreparations.get(id);
|
|
2805
|
+
if (preparation === void 0 || preparation.sourcePath !== selected.sourcePath || preparation.sourceRevision !== sourceRevision) {
|
|
2806
|
+
const controller = new AbortController();
|
|
2807
|
+
const promise = this.loadStoredMigration(id, selected, sourceRevision, controller.signal);
|
|
2808
|
+
preparation = {
|
|
2809
|
+
sourcePath: selected.sourcePath,
|
|
2810
|
+
sourceRevision,
|
|
2811
|
+
controller,
|
|
2812
|
+
promise,
|
|
2813
|
+
settled: false,
|
|
2814
|
+
waiters: 0
|
|
2815
|
+
};
|
|
2816
|
+
this.migrationPreparations.set(id, preparation);
|
|
2817
|
+
const created = preparation;
|
|
2818
|
+
const release = () => {
|
|
2819
|
+
created.settled = true;
|
|
2820
|
+
if (this.migrationPreparations.get(id) === created) this.migrationPreparations.delete(id);
|
|
2821
|
+
};
|
|
2822
|
+
promise.then(release, release);
|
|
2823
|
+
}
|
|
2824
|
+
signal?.throwIfAborted();
|
|
2825
|
+
return this.waitForPreparation(id, preparation, signal);
|
|
2826
|
+
}
|
|
2827
|
+
if (selected.sourceVersion > SESSION_FORMAT_VERSION) {
|
|
2828
|
+
/* v8 ignore else -- a readable future header is rejected inside readGenerationHeader. */
|
|
2829
|
+
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") });
|
|
2830
|
+
/* v8 ignore next -- readGenerationHeader rejects every future version. */
|
|
2831
|
+
throw new SessionFormatUnsupportedError(`${sessionFormatVersionRefusal(id, selected.sourceVersion)} (raw log: ${selected.sourcePath})`, {
|
|
2832
|
+
kind: "jsonl",
|
|
2833
|
+
path: selected.sourcePath
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
const probe = fileRevision(await stat(selected.sourcePath, { bigint: true }));
|
|
2837
|
+
const memoized = this.coldLogMemo.get(id);
|
|
2838
|
+
if (memoized?.status === "current" && memoized.revision === probe) {
|
|
2839
|
+
this.coldLogMemo.delete(id);
|
|
2840
|
+
this.coldLogMemo.set(id, memoized);
|
|
2841
|
+
return memoized;
|
|
2842
|
+
}
|
|
2843
|
+
const current = await readStableJsonlFile(selected.sourcePath, signal);
|
|
2844
|
+
return this.decodeStoredLog(selected.sourcePath, id, current.bytes, fileRevision(current.identity), signal);
|
|
2845
|
+
}
|
|
2846
|
+
/** Probe the memo and otherwise decode one historical generation under backend cancellation. */
|
|
2847
|
+
async loadStoredMigration(id, selected, sourceRevision, signal) {
|
|
2848
|
+
signal.throwIfAborted();
|
|
2849
|
+
const memoized = this.coldLogMemo.get(id);
|
|
2850
|
+
if (memoized?.status === "prepared" && memoized.revision === sourceRevision) {
|
|
2851
|
+
this.coldLogMemo.delete(id);
|
|
2852
|
+
this.coldLogMemo.set(id, memoized);
|
|
2853
|
+
return memoized;
|
|
2854
|
+
}
|
|
2855
|
+
return this.prepareStoredMigration(id, selected, signal);
|
|
2856
|
+
}
|
|
2857
|
+
/** Await shared preparation for one caller and abort it only after its last waiter leaves. */
|
|
2858
|
+
async waitForPreparation(id, preparation, signal) {
|
|
2859
|
+
preparation.waiters += 1;
|
|
2860
|
+
try {
|
|
2861
|
+
return await waitWithAbort(preparation.promise, signal);
|
|
2862
|
+
} finally {
|
|
2863
|
+
preparation.waiters -= 1;
|
|
2864
|
+
if (preparation.waiters === 0 && !preparation.settled) {
|
|
2865
|
+
/* v8 ignore else -- a newer selected source may already own this id's preparation slot. */
|
|
2866
|
+
if (this.migrationPreparations.get(id) === preparation) this.migrationPreparations.delete(id);
|
|
2867
|
+
preparation.controller.abort();
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
/** Decode one historical generation without publishing a successor. */
|
|
2872
|
+
async prepareStoredMigration(id, selected, signal) {
|
|
2873
|
+
let prepared;
|
|
2874
|
+
try {
|
|
2875
|
+
prepared = await prepareJsonlMigration({
|
|
2876
|
+
sourcePath: selected.sourcePath,
|
|
2877
|
+
sourceVersion: selected.sourceVersion,
|
|
2878
|
+
currentPath: selected.currentPath,
|
|
2879
|
+
compression: this.compression,
|
|
2880
|
+
format: this.generationFormat,
|
|
2881
|
+
verifyCurrentFile: verifyCurrentGenerationInWorker,
|
|
2882
|
+
validateHistoricalHeader: (headerValue) => this.validateSourceIdentity(selected, headerValue, id, signal),
|
|
2883
|
+
signal
|
|
2884
|
+
});
|
|
2885
|
+
} catch (error) {
|
|
2886
|
+
throw this.generationFailure(id, selected, error);
|
|
2887
|
+
}
|
|
2888
|
+
const meta = this.currentHeader(prepared.artifact.header);
|
|
2889
|
+
assertStoredId(id, meta);
|
|
2890
|
+
const events = prepared.artifact.events;
|
|
2891
|
+
validateStoredEvents(meta, events, {
|
|
2892
|
+
kind: "jsonl",
|
|
2893
|
+
path: selected.sourcePath
|
|
2894
|
+
});
|
|
2895
|
+
const stored = {
|
|
2896
|
+
status: "prepared",
|
|
2897
|
+
meta,
|
|
2898
|
+
...freezeStoredEvents(events),
|
|
2899
|
+
tornTruncateTo: void 0,
|
|
2900
|
+
recoveredTail: [],
|
|
2901
|
+
inheritedEventCount: SessionLogOffset(prepared.artifact.inheritedEventCount),
|
|
2902
|
+
revision: fileRevision(prepared.sourceIdentity),
|
|
2903
|
+
publication: {
|
|
2904
|
+
source: selected,
|
|
2905
|
+
value: prepared
|
|
2906
|
+
}
|
|
2907
|
+
};
|
|
2908
|
+
this.memoizeStoredLog(id, stored);
|
|
2909
|
+
return stored;
|
|
2910
|
+
}
|
|
2911
|
+
/** Publish a prepared historical log before granting write access. */
|
|
2912
|
+
async publishStoredMigration(id, stored) {
|
|
2913
|
+
const migration = stored.publication;
|
|
2914
|
+
let identity;
|
|
2915
|
+
try {
|
|
2916
|
+
identity = await migration.value.publish();
|
|
2917
|
+
} catch (error) {
|
|
2918
|
+
/* v8 ignore else -- a newer preparation may have replaced this stale cache entry. */
|
|
2919
|
+
if (this.coldLogMemo.get(id) === stored) this.coldLogMemo.delete(id);
|
|
2920
|
+
throw this.generationFailure(id, migration.source, error);
|
|
2921
|
+
}
|
|
2922
|
+
const published = {
|
|
2923
|
+
status: "current",
|
|
2924
|
+
meta: stored.meta,
|
|
2925
|
+
eventState: stored.eventState,
|
|
2926
|
+
events: stored.events,
|
|
2927
|
+
tornTruncateTo: stored.tornTruncateTo,
|
|
2928
|
+
recoveredTail: stored.recoveredTail,
|
|
2929
|
+
inheritedEventCount: stored.inheritedEventCount,
|
|
2930
|
+
revision: fileRevision(identity)
|
|
2931
|
+
};
|
|
2932
|
+
this.memoizeStoredLog(id, published);
|
|
2933
|
+
return published;
|
|
2934
|
+
}
|
|
2935
|
+
/** Translate generation-layer failures into the persistence seam's error vocabulary. */
|
|
2936
|
+
generationFailure(id, selected, error) {
|
|
2937
|
+
if (error instanceof JsonlGenerationUnsupportedMigrationError) return new SessionFormatUnsupportedError(`${error.message}; source v${error.fromVersion} artifact remains unchanged (raw log: ${selected.sourcePath})`, {
|
|
2938
|
+
kind: "jsonl",
|
|
2939
|
+
path: selected.sourcePath
|
|
2940
|
+
});
|
|
2941
|
+
if (error instanceof JsonlGenerationSourceChangedError) return error;
|
|
2942
|
+
if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError || isErrnoException(error) || error instanceof DOMException && error.name === "AbortError") return error;
|
|
2943
|
+
return new SessionPersistenceCorruptionError(`session "${id}": stored log is corrupt: ${String(error)} (raw log: ${selected.sourcePath})`, { cause: error });
|
|
2944
|
+
}
|
|
2945
|
+
/**
|
|
2946
|
+
* Read, parse, and validate one stored log as the current logical prefix.
|
|
2947
|
+
* @param path - the artifact file to read.
|
|
2948
|
+
* @param expectedId - the session identity the artifact must carry.
|
|
2949
|
+
* @param signal - optional cancellation for the stat/read/decode work.
|
|
2950
|
+
* @returns the validated stored log with any torn-tail truncation point.
|
|
2951
|
+
*/
|
|
2952
|
+
async readStoredLog(path, expectedId, signal) {
|
|
2953
|
+
signal?.throwIfAborted();
|
|
2954
|
+
const probe = fileRevision(await stat(path, { bigint: true }));
|
|
2955
|
+
const memoized = this.coldLogMemo.get(expectedId);
|
|
2956
|
+
if (memoized?.status === "current" && memoized.revision === probe) {
|
|
2957
|
+
this.coldLogMemo.delete(expectedId);
|
|
2958
|
+
this.coldLogMemo.set(expectedId, memoized);
|
|
2959
|
+
return memoized;
|
|
2960
|
+
}
|
|
2961
|
+
const { bytes, identity } = await readStableJsonlFile(path, signal);
|
|
2962
|
+
return this.decodeStoredLog(path, expectedId, bytes, fileRevision(identity), signal);
|
|
2963
|
+
}
|
|
2964
|
+
/** Decode and memoize one already-stable current physical snapshot. */
|
|
2965
|
+
async decodeStoredLog(path, expectedId, buffer, revision, signal) {
|
|
2966
|
+
let parsed;
|
|
2967
|
+
try {
|
|
2968
|
+
if (this.compression === "zstd") parsed = await this.readZstdPrefix(buffer, signal);
|
|
2969
|
+
else {
|
|
2970
|
+
signal?.throwIfAborted();
|
|
2971
|
+
const { meta, inheritedEventCount, events, committedBytes } = scanLog(buffer);
|
|
2972
|
+
signal?.throwIfAborted();
|
|
2973
|
+
parsed = {
|
|
2974
|
+
meta,
|
|
2975
|
+
inheritedEventCount,
|
|
2976
|
+
events,
|
|
2977
|
+
tornTruncateTo: committedBytes < buffer.byteLength ? committedBytes : void 0,
|
|
2978
|
+
recoveredTail: []
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
} catch (error) {
|
|
2982
|
+
signal?.throwIfAborted();
|
|
2983
|
+
if (error instanceof SessionFormatUnsupportedError) throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, {
|
|
2984
|
+
kind: "jsonl",
|
|
2985
|
+
path
|
|
2986
|
+
});
|
|
2987
|
+
throw new SessionPersistenceCorruptionError(`session "${expectedId}": stored log is corrupt: ${String(error)} (raw log: ${path})`, { cause: error });
|
|
2988
|
+
}
|
|
2989
|
+
signal?.throwIfAborted();
|
|
2990
|
+
await this.assertStoredIdentity(path, SESSION_FORMAT_VERSION, parsed.meta, expectedId, signal);
|
|
2991
|
+
signal?.throwIfAborted();
|
|
2992
|
+
assertStoredId(expectedId, parsed.meta);
|
|
2993
|
+
const location = this.locate(parsed.meta);
|
|
2994
|
+
validateStoredEvents(parsed.meta, parsed.events, location);
|
|
2995
|
+
const { events, ...rest } = parsed;
|
|
2996
|
+
const stored = {
|
|
2997
|
+
status: "current",
|
|
2998
|
+
...rest,
|
|
2999
|
+
...freezeStoredEvents(events),
|
|
3000
|
+
revision
|
|
3001
|
+
};
|
|
3002
|
+
this.memoizeStoredLog(expectedId, stored);
|
|
3003
|
+
return stored;
|
|
3004
|
+
}
|
|
3005
|
+
/** Insert one parsed log into the bounded handoff cache. */
|
|
3006
|
+
memoizeStoredLog(id, stored) {
|
|
3007
|
+
this.coldLogMemo.delete(id);
|
|
3008
|
+
this.coldLogMemo.set(id, stored);
|
|
3009
|
+
for (const oldest of this.coldLogMemo.keys()) {
|
|
3010
|
+
if (this.coldLogMemo.size <= COLD_LOG_MEMO_MAX_ENTRIES) break;
|
|
3011
|
+
this.coldLogMemo.delete(oldest);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* Resolve a session's current-generation log path.
|
|
3016
|
+
* @param id - the stored session to locate.
|
|
3017
|
+
* @param signal - optional cancellation for the directory scans.
|
|
3018
|
+
* @returns the current artifact path, or `undefined` while only a historical generation exists.
|
|
3019
|
+
*/
|
|
3020
|
+
async resolveCurrentLog(id, signal) {
|
|
3021
|
+
await this.ensureRootEncoding();
|
|
3022
|
+
signal?.throwIfAborted();
|
|
3023
|
+
const selected = await this.findLog(id, signal);
|
|
3024
|
+
if (selected === void 0) return void 0;
|
|
3025
|
+
if (selected.sourceVersion === SESSION_FORMAT_VERSION) return selected.sourcePath;
|
|
3026
|
+
if (selected.sourceVersion < SESSION_FORMAT_VERSION) return void 0;
|
|
3027
|
+
throw new SessionFormatUnsupportedError(`${sessionFormatVersionRefusal(id, selected.sourceVersion)} (raw log: ${selected.sourcePath})`, {
|
|
3028
|
+
kind: "jsonl",
|
|
3029
|
+
path: selected.sourcePath
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Rewrite one active session's current generation with an earlier event prefix.
|
|
3034
|
+
* Historical format generations are left in place.
|
|
3035
|
+
* @param id - the session whose write handle is active in this backend.
|
|
3036
|
+
* @param length - number of events to retain.
|
|
3037
|
+
* @returns resolution after the durable rewrite completes.
|
|
3038
|
+
*/
|
|
3039
|
+
async truncate(id, length) {
|
|
3040
|
+
const writer = this.tracker.writerOf(id);
|
|
3041
|
+
if (writer === void 0) throw new Error(`session "${id}" has no active write handle for destructive deletion`);
|
|
3042
|
+
await writer.drainLive();
|
|
3043
|
+
await writer.truncate(length);
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Durably append one validated batch; lazily materializes on the first write.
|
|
3047
|
+
* @param header - the session's stored header.
|
|
3048
|
+
* @param events - the validated contiguous batch, in seq order.
|
|
3049
|
+
* @param isMaterialized - whether the session already has a durable artifact.
|
|
3050
|
+
* @param inheritedEventCount - the exact fork-inherited prefix length written into a materializing header line.
|
|
3051
|
+
*/
|
|
3052
|
+
async persistBatch(header, events, isMaterialized, inheritedEventCount) {
|
|
3053
|
+
this.coldLogMemo.delete(header.id);
|
|
3054
|
+
await this.ensureRootEncoding();
|
|
3055
|
+
if (isMaterialized) await this.appendLines(header, events);
|
|
3056
|
+
else {
|
|
3057
|
+
await this.materialize(header, inheritedEventCount, events);
|
|
3058
|
+
this.tracker.materialized(header.id);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Materialize a header-only artifact for an explicitly durable empty session.
|
|
3063
|
+
* @param header - the session's stored header.
|
|
3064
|
+
* @param inheritedEventCount - the exact fork-inherited prefix length written into the header line.
|
|
3065
|
+
*/
|
|
3066
|
+
async persistHeader(header, inheritedEventCount) {
|
|
3067
|
+
this.coldLogMemo.delete(header.id);
|
|
3068
|
+
await this.ensureRootEncoding();
|
|
3069
|
+
await this.materialize(header, inheritedEventCount, []);
|
|
3070
|
+
this.tracker.materialized(header.id);
|
|
3071
|
+
}
|
|
3072
|
+
/**
|
|
3073
|
+
* Truncate a torn physical tail durably before this session's first new append.
|
|
3074
|
+
* @param header - the session's stored header.
|
|
3075
|
+
* @param truncateTo - the byte offset the artifact is truncated to.
|
|
3076
|
+
*/
|
|
3077
|
+
async truncateTornTail(header, truncateTo) {
|
|
3078
|
+
this.coldLogMemo.delete(header.id);
|
|
3079
|
+
await this.repair(header, truncateTo);
|
|
3080
|
+
this.ctx.logger.warn(`${this.name}: session "${header.id}" recovered from a torn tail; incomplete tail bytes were discarded`);
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* Whether this process still tracks a created-but-unmaterialized session.
|
|
3084
|
+
* @param id - the session to test.
|
|
3085
|
+
* @returns true while the pending entry exists.
|
|
3086
|
+
*/
|
|
3087
|
+
hasPendingSession(id) {
|
|
3088
|
+
return this.tracker.hasPending(id);
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Release one handle's backend bookkeeping on close.
|
|
3092
|
+
* @param handle - the closing handle.
|
|
3093
|
+
* @param materialized - whether the session reached durable storage.
|
|
3094
|
+
*/
|
|
3095
|
+
releaseHandle(handle, materialized) {
|
|
3096
|
+
this.tracker.release(handle, materialized);
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
3099
|
+
* Acquire the session directory's kernel write lock; the kernel holds it
|
|
3100
|
+
* until the handle's close releases the descriptor, including on process death.
|
|
3101
|
+
* @param id - the session the lock guards.
|
|
3102
|
+
* @param cwd - header cwd used to derive the directory for a fresh session.
|
|
3103
|
+
* @param dir - the resolved directory of an existing artifact, when known.
|
|
3104
|
+
* @returns the held lock.
|
|
3105
|
+
*/
|
|
3106
|
+
acquireLease(id, cwd, dir = sessionDir(this.root, cwd, id)) {
|
|
3107
|
+
return SessionWriteLease.acquire(dir, id);
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Acquire the cross-process write lock for a materializing created session,
|
|
3111
|
+
* called by its handle immediately before the first log bytes publish.
|
|
3112
|
+
* @param header - the session's stored header (its cwd derives the directory).
|
|
3113
|
+
* @returns the held lock.
|
|
3114
|
+
*/
|
|
3115
|
+
async acquireWriteLease(header) {
|
|
3116
|
+
await this.rejectOppositeArtifact(header.cwd, header.id);
|
|
3117
|
+
return this.acquireLease(header.id, header.cwd);
|
|
3118
|
+
}
|
|
3119
|
+
/** Decode complete frames and retain complete JSONL records from a torn final frame. */
|
|
3120
|
+
async readZstdPrefix(buffer, signal) {
|
|
3121
|
+
signal?.throwIfAborted();
|
|
3122
|
+
const { frames, tornStart } = scanZstdFrames(buffer);
|
|
3123
|
+
signal?.throwIfAborted();
|
|
3124
|
+
if (frames.length === 0) throw new Error("empty or header-less Zstandard session log");
|
|
3125
|
+
const decoder = createZstdFrameDecoder();
|
|
3126
|
+
let yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS;
|
|
3127
|
+
try {
|
|
3128
|
+
const decodedFrames = decoder.decode(buffer, frames);
|
|
3129
|
+
signal?.throwIfAborted();
|
|
3130
|
+
const headerFrame = decodedFrames.next();
|
|
3131
|
+
signal?.throwIfAborted();
|
|
3132
|
+
/* v8 ignore next -- a non-empty structural frame list makes the decoder yield its first frame or throw. */
|
|
3133
|
+
if (headerFrame.done) throw new Error("empty or header-less Zstandard session log");
|
|
3134
|
+
assertZstdHeaderFrame(headerFrame.value);
|
|
3135
|
+
const scanner = new SessionLogScanner(headerFrame.value);
|
|
3136
|
+
let remainingFrames = frames.length - 1;
|
|
3137
|
+
for (const plaintext of decodedFrames) {
|
|
3138
|
+
signal?.throwIfAborted();
|
|
3139
|
+
scanner.write(plaintext);
|
|
3140
|
+
remainingFrames -= 1;
|
|
3141
|
+
if (remainingFrames > 0 && performance.now() >= yieldDeadline) {
|
|
3142
|
+
await scheduler.yield();
|
|
3143
|
+
signal?.throwIfAborted();
|
|
3144
|
+
yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
signal?.throwIfAborted();
|
|
3148
|
+
const complete = scanner.checkpoint();
|
|
3149
|
+
if (complete.committedBytes !== complete.inputBytes) throw new Error("corrupt Zstandard session log: complete frame contains a torn JSONL record");
|
|
3150
|
+
if (tornStart === void 0) {
|
|
3151
|
+
const prefix = scanner.finish();
|
|
3152
|
+
return {
|
|
3153
|
+
meta: prefix.meta,
|
|
3154
|
+
inheritedEventCount: prefix.inheritedEventCount,
|
|
3155
|
+
events: prefix.events,
|
|
3156
|
+
tornTruncateTo: void 0,
|
|
3157
|
+
recoveredTail: []
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
let recoveredPlaintext = Buffer.alloc(0);
|
|
3161
|
+
try {
|
|
3162
|
+
signal?.throwIfAborted();
|
|
3163
|
+
recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart));
|
|
3164
|
+
} catch {
|
|
3165
|
+
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
|
|
3166
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
3167
|
+
}
|
|
3168
|
+
signal?.throwIfAborted();
|
|
3169
|
+
scanner.write(recoveredPlaintext);
|
|
3170
|
+
const prefix = scanner.finish();
|
|
3171
|
+
return {
|
|
3172
|
+
meta: prefix.meta,
|
|
3173
|
+
inheritedEventCount: prefix.inheritedEventCount,
|
|
3174
|
+
events: prefix.events,
|
|
3175
|
+
tornTruncateTo: tornStart,
|
|
3176
|
+
recoveredTail: prefix.events.slice(complete.eventCount)
|
|
3177
|
+
};
|
|
3178
|
+
} catch (error) {
|
|
3179
|
+
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
|
|
3180
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
3181
|
+
throw error;
|
|
3182
|
+
} finally {
|
|
3183
|
+
decoder.close();
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
async listArtifacts(signal) {
|
|
3187
|
+
signal?.throwIfAborted();
|
|
3188
|
+
await this.ensureRootEncoding();
|
|
3189
|
+
signal?.throwIfAborted();
|
|
3190
|
+
const artifacts = [];
|
|
3191
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3192
|
+
for (const project of await this.listProjectDirs(signal)) {
|
|
3193
|
+
signal?.throwIfAborted();
|
|
3194
|
+
for (const dir of await this.listSessionDirs(project, signal)) {
|
|
3195
|
+
signal?.throwIfAborted();
|
|
3196
|
+
const selected = await this.resolveGenerationInDirectory(dir, signal);
|
|
3197
|
+
if (selected === void 0) continue;
|
|
3198
|
+
let header;
|
|
3199
|
+
try {
|
|
3200
|
+
header = await this.readGenerationHeader(selected, void 0, signal);
|
|
3201
|
+
} catch (error) {
|
|
3202
|
+
if (error instanceof SessionFormatUnsupportedError) continue;
|
|
3203
|
+
throw error;
|
|
3204
|
+
}
|
|
3205
|
+
if (header === void 0) continue;
|
|
3206
|
+
if (ids.has(header.id)) throw new Error(`duplicate JSONL session id "${header.id}" appears in multiple project directories`);
|
|
3207
|
+
ids.add(header.id);
|
|
3208
|
+
artifacts.push({
|
|
3209
|
+
header,
|
|
3210
|
+
path: selected.sourcePath
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
signal?.throwIfAborted();
|
|
3215
|
+
return artifacts;
|
|
3216
|
+
}
|
|
3217
|
+
/** Read and translate one selected generation header without inspecting its body. */
|
|
3218
|
+
async readGenerationHeader(selected, expectedId, signal) {
|
|
3219
|
+
let first;
|
|
3220
|
+
try {
|
|
3221
|
+
first = this.compression === "zstd" ? await this.readFirstZstdLine(selected.sourcePath, signal) : await this.readFirstLine(selected.sourcePath, signal);
|
|
3222
|
+
} catch (error) {
|
|
3223
|
+
signal?.throwIfAborted();
|
|
3224
|
+
if (isENOENT(error)) return void 0;
|
|
3225
|
+
throw error;
|
|
3226
|
+
}
|
|
3227
|
+
signal?.throwIfAborted();
|
|
3228
|
+
if (first === void 0) return void 0;
|
|
3229
|
+
let value;
|
|
3230
|
+
try {
|
|
3231
|
+
value = JSON.parse(first);
|
|
3232
|
+
} catch {
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
3235
|
+
assertNoRetiredHeaderFields(value);
|
|
3236
|
+
const result = sessionFormatCatalog.readHeader(value);
|
|
3237
|
+
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}`);
|
|
3238
|
+
if (result.status === "unsupported") {
|
|
3239
|
+
const physicalId = String(value.id);
|
|
3240
|
+
let reason = result.reason;
|
|
3241
|
+
/* v8 ignore else -- released historical header migrations cannot refuse after physical decoding. */
|
|
3242
|
+
if (result.storedVersion > SESSION_FORMAT_VERSION) reason = sessionFormatVersionRefusal(physicalId, result.storedVersion);
|
|
3243
|
+
throw new SessionFormatUnsupportedError(`${reason} (raw log: ${selected.sourcePath})`, {
|
|
3244
|
+
kind: "jsonl",
|
|
3245
|
+
path: selected.sourcePath
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
if (result.status === "malformed") return void 0;
|
|
3249
|
+
const header = this.currentHeader(result.header);
|
|
3250
|
+
await this.assertStoredIdentity(selected.sourcePath, selected.sourceVersion, header, expectedId, signal);
|
|
3251
|
+
return header;
|
|
3252
|
+
}
|
|
3253
|
+
/** Convert format-catalog string identities to current branded Session metadata. */
|
|
3254
|
+
currentHeader(header) {
|
|
3255
|
+
/* v8 ignore next 3 -- readable catalog results are restored to its configured current version. */
|
|
3256
|
+
if (header.version !== SESSION_FORMAT_VERSION) throw new Error(`format catalog returned non-current logical header v${header.version}`);
|
|
3257
|
+
return {
|
|
3258
|
+
version: SESSION_FORMAT_VERSION,
|
|
3259
|
+
id: SessionId(header.id),
|
|
3260
|
+
createdAt: header.createdAt,
|
|
3261
|
+
...header.cwd === void 0 ? {} : { cwd: header.cwd },
|
|
3262
|
+
...header.parentSession === void 0 ? {} : { parentSession: SessionId(header.parentSession) },
|
|
3263
|
+
isSeeded: header.isSeeded,
|
|
3264
|
+
...header.origin === void 0 ? {} : { origin: header.origin },
|
|
3265
|
+
delegationDepth: header.delegationDepth,
|
|
3266
|
+
...header.agentPreset === void 0 ? {} : { agentPreset: header.agentPreset }
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* Rewrite the current generation with a contiguous event prefix.
|
|
3271
|
+
* Predecessor format generations are not moved, overwritten, or deleted.
|
|
3272
|
+
* @param header - immutable session metadata stored in the header line.
|
|
3273
|
+
* @param inheritedEventCount - exact fork-inherited prefix count.
|
|
3274
|
+
* @param events - complete retained event prefix.
|
|
3275
|
+
* @returns resolution after the replacement is durable.
|
|
3276
|
+
*/
|
|
3277
|
+
async rewrite(header, inheritedEventCount, events) {
|
|
3278
|
+
this.coldLogMemo.delete(header.id);
|
|
3279
|
+
await this.ensureRootEncoding();
|
|
3280
|
+
await this.rejectOppositeArtifact(header.cwd, header.id);
|
|
3281
|
+
const finalPath = logPath(this.root, header.cwd, header.id, this.compression);
|
|
3282
|
+
if (!await this.exists(finalPath)) {
|
|
3283
|
+
if (this.tracker.hasPending(header.id)) {
|
|
3284
|
+
await this.materialize(header, inheritedEventCount, events);
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
throw new SessionPersistenceNotFoundError(header.id);
|
|
3288
|
+
}
|
|
3289
|
+
const content = await this.encodeMaterialization(header, inheritedEventCount, events);
|
|
3290
|
+
const tmp = await this.writeSyncedTempFile(finalPath, content);
|
|
3291
|
+
let published = false;
|
|
3292
|
+
try {
|
|
3293
|
+
/* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
|
|
3294
|
+
if (process.platform === "win32") {
|
|
3295
|
+
await rm(finalPath, { force: true });
|
|
3296
|
+
await publishNewFileWin32(tmp, finalPath);
|
|
3297
|
+
} else {
|
|
3298
|
+
await rename(tmp, finalPath);
|
|
3299
|
+
await this.syncDirPosix(dirname(finalPath));
|
|
3300
|
+
}
|
|
3301
|
+
published = true;
|
|
3302
|
+
} finally {
|
|
3303
|
+
if (!published) await rm(tmp, { force: true });
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
/** Atomically write the header line + first batch (temp-write, fsync, publish). */
|
|
3307
|
+
async materialize(meta, inheritedEventCount, events) {
|
|
3308
|
+
const project = projectDir(this.root, meta.cwd);
|
|
3309
|
+
const dir = sessionDir(this.root, meta.cwd, meta.id);
|
|
3310
|
+
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression);
|
|
3311
|
+
await this.rejectOppositeArtifact(meta.cwd, meta.id);
|
|
3312
|
+
const content = await this.encodeMaterialization(meta, inheritedEventCount, events);
|
|
3313
|
+
/* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
|
|
3314
|
+
if (process.platform === "win32") await this.materializeWin32(project, dir, finalPath, meta.id, content);
|
|
3315
|
+
else await this.materializePosix(project, dir, finalPath, meta.id, content);
|
|
3316
|
+
}
|
|
3317
|
+
/* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
|
|
3318
|
+
async materializePosix(project, dir, finalPath, id, content) {
|
|
3319
|
+
await mkdir(this.root, {
|
|
3320
|
+
recursive: true,
|
|
3321
|
+
mode: 448
|
|
3322
|
+
});
|
|
3323
|
+
await this.syncDirPosix(dirname(this.root));
|
|
3324
|
+
await mkdir(project, {
|
|
3325
|
+
recursive: true,
|
|
3326
|
+
mode: 448
|
|
3327
|
+
});
|
|
3328
|
+
await this.syncDirPosix(this.root);
|
|
3329
|
+
await mkdir(dir, {
|
|
3330
|
+
recursive: true,
|
|
3331
|
+
mode: 448
|
|
3332
|
+
});
|
|
3333
|
+
await this.syncDirPosix(project);
|
|
3334
|
+
await this.rejectExistingLog(finalPath, id);
|
|
3335
|
+
const tmp = await this.writeSyncedTempFile(finalPath, content);
|
|
3336
|
+
let linked = false;
|
|
3337
|
+
try {
|
|
3338
|
+
await link(tmp, finalPath);
|
|
3339
|
+
linked = true;
|
|
3340
|
+
} finally {
|
|
3341
|
+
/* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
|
|
3342
|
+
if (!linked) await rm(tmp, { force: true });
|
|
3343
|
+
}
|
|
3344
|
+
await this.syncDirPosix(dir);
|
|
3345
|
+
try {
|
|
3346
|
+
await rm(tmp, { force: true });
|
|
3347
|
+
} catch {}
|
|
3348
|
+
}
|
|
3349
|
+
/* v8 ignore stop */
|
|
3350
|
+
/* v8 ignore start -- native Windows coverage exercises this integration path */
|
|
3351
|
+
async materializeWin32(project, dir, finalPath, id, content) {
|
|
3352
|
+
await ensureDurableDirectoryWin32(this.root);
|
|
3353
|
+
await ensureDurableDirectoryWin32(project);
|
|
3354
|
+
await ensureDurableDirectoryWin32(dir);
|
|
3355
|
+
await this.rejectExistingLog(finalPath, id);
|
|
3356
|
+
const tmp = await this.writeSyncedTempFile(finalPath, content);
|
|
3357
|
+
try {
|
|
3358
|
+
await publishNewFileWin32(tmp, finalPath);
|
|
3359
|
+
} catch (error) {
|
|
3360
|
+
await rm(tmp, { force: true });
|
|
3361
|
+
throw error;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
/* v8 ignore stop */
|
|
3365
|
+
async rejectExistingLog(finalPath, id) {
|
|
3366
|
+
/* v8 ignore next 3 -- create guards collisions before materialize; this is a TOCTOU backstop */
|
|
3367
|
+
if (await this.resolveGenerationInDirectory(dirname(finalPath)) !== void 0) throw new Error(`refusing to materialize "${id}": a log already exists on disk (open it instead)`);
|
|
3368
|
+
}
|
|
3369
|
+
async writeSyncedTempFile(finalPath, content) {
|
|
3370
|
+
const tmp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
3371
|
+
const handle = await open(tmp, "wx", 384);
|
|
3372
|
+
try {
|
|
3373
|
+
await handle.writeFile(content);
|
|
3374
|
+
await handle.sync();
|
|
3375
|
+
} finally {
|
|
3376
|
+
await handle.close();
|
|
3377
|
+
}
|
|
3378
|
+
return tmp;
|
|
3379
|
+
}
|
|
3380
|
+
/** Encode the header and first batch without combining their frame boundaries. */
|
|
3381
|
+
async encodeMaterialization(meta, inheritedEventCount, events) {
|
|
3382
|
+
const header = JSON.stringify(toHeaderLine(meta, meta.isSeeded ? inheritedEventCount : void 0)) + "\n";
|
|
3383
|
+
if (events.length === 0) return this.compression === "none" ? header : compressZstdFrame(header);
|
|
3384
|
+
const body = eventLines(events) + "\n";
|
|
3385
|
+
if (this.compression === "none") return header + body;
|
|
3386
|
+
const headerFrame = await compressZstdFrame(header);
|
|
3387
|
+
const eventFrame = await compressZstdFrame(body);
|
|
3388
|
+
return Buffer.concat([headerFrame, eventFrame]);
|
|
3389
|
+
}
|
|
3390
|
+
/** Encode one durable append batch in the configured physical representation. */
|
|
3391
|
+
async encodeEventBatch(events) {
|
|
3392
|
+
const body = eventLines(events) + "\n";
|
|
3393
|
+
return this.compression === "zstd" ? compressZstdFrame(body) : body;
|
|
3394
|
+
}
|
|
3395
|
+
/** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
|
|
3396
|
+
/* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */
|
|
3397
|
+
async syncDirPosix(dir) {
|
|
3398
|
+
const handle = await open(dir, "r");
|
|
3399
|
+
try {
|
|
3400
|
+
await handle.sync();
|
|
3401
|
+
} finally {
|
|
3402
|
+
await handle.close();
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
/* v8 ignore stop */
|
|
3406
|
+
/**
|
|
3407
|
+
* Append and fsync event lines. On a partial write or sync failure, restore the
|
|
3408
|
+
* previous size before rethrowing because the unchanged cursor will retry the
|
|
3409
|
+
* batch; leaving partial bytes would create duplicate sequence numbers.
|
|
3410
|
+
*/
|
|
3411
|
+
async appendLines(meta, events) {
|
|
3412
|
+
const content = await this.encodeEventBatch(events);
|
|
3413
|
+
const path = logPath(this.root, meta.cwd, meta.id, this.compression);
|
|
3414
|
+
const handle = await open(path, "a");
|
|
3415
|
+
let closed = false;
|
|
3416
|
+
const closeAppendHandle = async () => {
|
|
3417
|
+
if (closed) return;
|
|
3418
|
+
closed = true;
|
|
3419
|
+
await handle.close();
|
|
3420
|
+
};
|
|
3421
|
+
try {
|
|
3422
|
+
const { size: before } = await handle.stat();
|
|
3423
|
+
try {
|
|
3424
|
+
await handle.writeFile(content);
|
|
3425
|
+
await handle.sync();
|
|
3426
|
+
} catch (error) {
|
|
3427
|
+
try {
|
|
3428
|
+
await closeAppendHandle();
|
|
3429
|
+
await this.rollbackAppend(path, before);
|
|
3430
|
+
} catch (rollbackError) {
|
|
3431
|
+
throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`);
|
|
3432
|
+
}
|
|
3433
|
+
throw error;
|
|
3434
|
+
}
|
|
3435
|
+
} finally {
|
|
3436
|
+
await closeAppendHandle();
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
async rollbackAppend(path, size) {
|
|
3440
|
+
const handle = await open(path, "r+");
|
|
3441
|
+
try {
|
|
3442
|
+
await handle.truncate(size);
|
|
3443
|
+
await handle.sync();
|
|
3444
|
+
} finally {
|
|
3445
|
+
await handle.close();
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
/** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */
|
|
3449
|
+
async repair(meta, offset) {
|
|
3450
|
+
const path = logPath(this.root, meta.cwd, meta.id, this.compression);
|
|
3451
|
+
await truncate(path, offset);
|
|
3452
|
+
const handle = await open(path, "r+");
|
|
3453
|
+
try {
|
|
3454
|
+
await handle.sync();
|
|
3455
|
+
} finally {
|
|
3456
|
+
await handle.close();
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* Read the first newline-terminated line of a file without loading the whole
|
|
3461
|
+
* file. Returns undefined if the file is empty or has no complete first line.
|
|
3462
|
+
* Reads in bounded chunks so a huge log costs only the header read.
|
|
3463
|
+
*/
|
|
3464
|
+
async readFirstLine(path, signal) {
|
|
3465
|
+
signal?.throwIfAborted();
|
|
3466
|
+
const handle = await open(path, "r");
|
|
3467
|
+
try {
|
|
3468
|
+
signal?.throwIfAborted();
|
|
3469
|
+
const chunks = [];
|
|
3470
|
+
const buf = Buffer.alloc(8192);
|
|
3471
|
+
for (;;) {
|
|
3472
|
+
signal?.throwIfAborted();
|
|
3473
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, null);
|
|
3474
|
+
signal?.throwIfAborted();
|
|
3475
|
+
if (bytesRead === 0) return void 0;
|
|
3476
|
+
const slice = buf.subarray(0, bytesRead);
|
|
3477
|
+
const nl = slice.indexOf(10);
|
|
3478
|
+
if (nl !== -1) {
|
|
3479
|
+
chunks.push(slice.subarray(0, nl));
|
|
3480
|
+
signal?.throwIfAborted();
|
|
3481
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3482
|
+
}
|
|
3483
|
+
chunks.push(Buffer.from(slice));
|
|
3484
|
+
}
|
|
3485
|
+
} finally {
|
|
3486
|
+
await handle.close();
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
/** Read and validate only the independently compressed header frame. */
|
|
3490
|
+
async readFirstZstdLine(path, signal) {
|
|
3491
|
+
signal?.throwIfAborted();
|
|
3492
|
+
const handle = await open(path, "r");
|
|
3493
|
+
try {
|
|
3494
|
+
signal?.throwIfAborted();
|
|
3495
|
+
let content = Buffer.alloc(0);
|
|
3496
|
+
const chunk = Buffer.alloc(8192);
|
|
3497
|
+
for (;;) {
|
|
3498
|
+
signal?.throwIfAborted();
|
|
3499
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
3500
|
+
signal?.throwIfAborted();
|
|
3501
|
+
if (bytesRead === 0) return void 0;
|
|
3502
|
+
signal?.throwIfAborted();
|
|
3503
|
+
content = Buffer.concat([content, chunk.subarray(0, bytesRead)]);
|
|
3504
|
+
signal?.throwIfAborted();
|
|
3505
|
+
const first = scanZstdFrames(content, 1).frames[0];
|
|
3506
|
+
signal?.throwIfAborted();
|
|
3507
|
+
if (first === void 0) continue;
|
|
3508
|
+
let plaintext;
|
|
3509
|
+
try {
|
|
3510
|
+
signal?.throwIfAborted();
|
|
3511
|
+
plaintext = await decompressZstdFrame(content.subarray(first.start, first.end));
|
|
3512
|
+
} catch (error) {
|
|
3513
|
+
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
|
|
3514
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
3515
|
+
throw new Error("corrupt Zstandard session log: header frame failed validation", { cause: error });
|
|
3516
|
+
}
|
|
3517
|
+
signal?.throwIfAborted();
|
|
3518
|
+
assertZstdHeaderFrame(plaintext);
|
|
3519
|
+
return plaintext.subarray(0, -1).toString("utf8");
|
|
3520
|
+
}
|
|
3521
|
+
} finally {
|
|
3522
|
+
await handle.close();
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
/** Select the numerically highest canonical generation in one Session directory. */
|
|
3526
|
+
async resolveGenerationInDirectory(dir, signal) {
|
|
3527
|
+
signal?.throwIfAborted();
|
|
3528
|
+
let entries;
|
|
3529
|
+
try {
|
|
3530
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
3531
|
+
} catch (error) {
|
|
3532
|
+
if (isENOENT(error)) return void 0;
|
|
3533
|
+
throw error;
|
|
3534
|
+
}
|
|
3535
|
+
signal?.throwIfAborted();
|
|
3536
|
+
const generations = [];
|
|
3537
|
+
const opposite = [];
|
|
3538
|
+
for (const entry of entries) {
|
|
3539
|
+
const version = parseGenerationLogFilename(entry.name, this.compression);
|
|
3540
|
+
if (version !== void 0) {
|
|
3541
|
+
generations.push({
|
|
3542
|
+
path: join(dir, entry.name),
|
|
3543
|
+
version
|
|
3544
|
+
});
|
|
3545
|
+
continue;
|
|
3546
|
+
}
|
|
3547
|
+
if (parseGenerationLogFilename(entry.name, this.oppositeCompression()) !== void 0) opposite.push(join(dir, entry.name));
|
|
3548
|
+
}
|
|
3549
|
+
if (opposite.length > 0) throw this.encodingMismatch(opposite[0]);
|
|
3550
|
+
const latest = generations.sort((left, right) => right.version - left.version)[0];
|
|
3551
|
+
if (latest === void 0) return void 0;
|
|
3552
|
+
return {
|
|
3553
|
+
sourcePath: latest.path,
|
|
3554
|
+
sourceVersion: latest.version,
|
|
3555
|
+
currentPath: join(dir, generationLogFilename(sessionFormatCatalog.currentVersion, this.compression))
|
|
3556
|
+
};
|
|
3557
|
+
}
|
|
3558
|
+
/** Find the unique authoritative generation for an id across project directories. */
|
|
3559
|
+
async findLog(id, signal) {
|
|
3560
|
+
const matches = [];
|
|
3561
|
+
for (const project of await this.listProjectDirs(signal)) {
|
|
3562
|
+
signal?.throwIfAborted();
|
|
3563
|
+
await this.rejectLegacyFlatArtifact(project, id, signal);
|
|
3564
|
+
signal?.throwIfAborted();
|
|
3565
|
+
const dir = join(project, encodeSegment(id));
|
|
3566
|
+
const selected = await this.resolveGenerationInDirectory(dir, signal);
|
|
3567
|
+
if (selected !== void 0) matches.push(selected);
|
|
3568
|
+
}
|
|
3569
|
+
if (matches.length > 1) throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`);
|
|
3570
|
+
signal?.throwIfAborted();
|
|
3571
|
+
return matches[0];
|
|
3572
|
+
}
|
|
3573
|
+
/** Require an existing configured root to be a readable directory. */
|
|
3574
|
+
assertUsableRoot() {
|
|
3575
|
+
try {
|
|
3576
|
+
readdirSync(this.root);
|
|
3577
|
+
} catch (error) {
|
|
3578
|
+
if (isENOENT(error)) return;
|
|
3579
|
+
throw error;
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
/** Reject metadata that does not identify the selected physical log. */
|
|
3583
|
+
async assertStoredIdentity(path, storedVersion, meta, expectedId, signal) {
|
|
3584
|
+
signal?.throwIfAborted();
|
|
3585
|
+
if (expectedId !== void 0 && meta.id !== expectedId) throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`);
|
|
3586
|
+
let expectedPath;
|
|
3587
|
+
try {
|
|
3588
|
+
expectedPath = generationLogPath(this.root, meta.cwd, meta.id, storedVersion, this.compression);
|
|
3589
|
+
} catch (error) {
|
|
3590
|
+
throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error });
|
|
3591
|
+
}
|
|
3592
|
+
if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`);
|
|
3593
|
+
signal?.throwIfAborted();
|
|
3594
|
+
}
|
|
3595
|
+
/** Validate a supported historical header against the selected source path. */
|
|
3596
|
+
validateSourceIdentity(selected, headerValue, expectedId, signal) {
|
|
3597
|
+
const result = sessionFormatCatalog.readHeader(headerValue);
|
|
3598
|
+
if (result.status !== "current" && result.status !== "migration-required") return;
|
|
3599
|
+
return this.assertStoredIdentity(selected.sourcePath, selected.sourceVersion, this.currentHeader(result.header), expectedId, signal);
|
|
3600
|
+
}
|
|
3601
|
+
/**
|
|
3602
|
+
* Whether two path spellings resolve to the same physical file. This admits
|
|
3603
|
+
* case aliases on case-insensitive filesystems without weakening identity
|
|
3604
|
+
* checks on case-sensitive stores.
|
|
3605
|
+
*/
|
|
3606
|
+
async sameFile(path, expectedPath, signal) {
|
|
3607
|
+
signal?.throwIfAborted();
|
|
3608
|
+
try {
|
|
3609
|
+
const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)]);
|
|
3610
|
+
signal?.throwIfAborted();
|
|
3611
|
+
return actual === expected;
|
|
3612
|
+
} catch (error) {
|
|
3613
|
+
signal?.throwIfAborted();
|
|
3614
|
+
/* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */
|
|
3615
|
+
if (isENOENT(error)) return false;
|
|
3616
|
+
/* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */
|
|
3617
|
+
throw error;
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
/** The human-readable project directories under the configured root. */
|
|
3621
|
+
async listProjectDirs(signal) {
|
|
3622
|
+
try {
|
|
3623
|
+
signal?.throwIfAborted();
|
|
3624
|
+
const entries = await readdir(this.root, { withFileTypes: true });
|
|
3625
|
+
signal?.throwIfAborted();
|
|
3626
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join(this.root, e.name));
|
|
3627
|
+
} catch (error) {
|
|
3628
|
+
if (isENOENT(error)) return [];
|
|
3629
|
+
throw error;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
/** List session-owned directories and reject the obsolete flat-file layout. */
|
|
3633
|
+
async listSessionDirs(project, signal) {
|
|
3634
|
+
signal?.throwIfAborted();
|
|
3635
|
+
const entries = await readdir(project, { withFileTypes: true });
|
|
3636
|
+
signal?.throwIfAborted();
|
|
3637
|
+
const legacy = entries.find((entry) => entry.isFile() && (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")));
|
|
3638
|
+
if (legacy !== void 0) throw this.legacyLayout(join(project, legacy.name));
|
|
3639
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => join(project, entry.name));
|
|
3640
|
+
}
|
|
3641
|
+
/** Reject a root that already belongs to the other physical encoding. */
|
|
3642
|
+
ensureRootEncoding() {
|
|
3643
|
+
this.rootEncodingCheck ??= this.checkRootEncoding();
|
|
3644
|
+
return this.rootEncodingCheck;
|
|
3645
|
+
}
|
|
3646
|
+
async checkRootEncoding() {
|
|
3647
|
+
for (const project of await this.listProjectDirs()) for (const dir of await this.listSessionDirs(project)) {
|
|
3648
|
+
const incompatible = await this.findOppositeGenerationInDirectory(dir);
|
|
3649
|
+
if (incompatible !== void 0) throw this.encodingMismatch(incompatible);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
async rejectLegacyFlatArtifact(project, id, signal) {
|
|
3653
|
+
signal?.throwIfAborted();
|
|
3654
|
+
const encoded = encodeSegment(id);
|
|
3655
|
+
for (const compression of ["zstd", "none"]) {
|
|
3656
|
+
const path = join(project, encoded + logSuffix(compression));
|
|
3657
|
+
const artifactExists = await this.exists(path);
|
|
3658
|
+
signal?.throwIfAborted();
|
|
3659
|
+
if (artifactExists) throw this.legacyLayout(path);
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
async rejectOppositeArtifact(cwd, id) {
|
|
3663
|
+
const path = await this.findOppositeGenerationInDirectory(sessionDir(this.root, cwd, id));
|
|
3664
|
+
if (path !== void 0) throw this.encodingMismatch(path);
|
|
3665
|
+
}
|
|
3666
|
+
/** Return the highest canonical generation encoded with the other configured suffix. */
|
|
3667
|
+
async findOppositeGenerationInDirectory(dir) {
|
|
3668
|
+
let entries;
|
|
3669
|
+
try {
|
|
3670
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
3671
|
+
} catch (error) {
|
|
3672
|
+
if (isENOENT(error)) return void 0;
|
|
3673
|
+
throw error;
|
|
3674
|
+
}
|
|
3675
|
+
const generations = [];
|
|
3676
|
+
for (const entry of entries) {
|
|
3677
|
+
const version = parseGenerationLogFilename(entry.name, this.oppositeCompression());
|
|
3678
|
+
if (version !== void 0) generations.push({
|
|
3679
|
+
name: entry.name,
|
|
3680
|
+
version
|
|
3681
|
+
});
|
|
3682
|
+
}
|
|
3683
|
+
const latest = generations.sort((left, right) => right.version - left.version)[0];
|
|
3684
|
+
return latest === void 0 ? void 0 : join(dir, latest.name);
|
|
3685
|
+
}
|
|
3686
|
+
oppositeCompression() {
|
|
3687
|
+
return this.compression === "zstd" ? "none" : "zstd";
|
|
3688
|
+
}
|
|
3689
|
+
encodingMismatch(path) {
|
|
3690
|
+
return /* @__PURE__ */ new Error(`session artifact ${JSON.stringify(path)} uses ${logSuffix(this.oppositeCompression())}, but this backend is configured for compression ${JSON.stringify(this.compression)}; use a separate root or select the matching compression mode`);
|
|
3691
|
+
}
|
|
3692
|
+
legacyLayout(path) {
|
|
3693
|
+
return /* @__PURE__ */ new Error(`session artifact ${JSON.stringify(path)} uses the unsupported flat-file layout; use a separate root or move it into a project/session directory before loading`);
|
|
3694
|
+
}
|
|
3695
|
+
async exists(path) {
|
|
3696
|
+
try {
|
|
3697
|
+
await (await open(path, "r")).close();
|
|
3698
|
+
return true;
|
|
3699
|
+
} catch (error) {
|
|
3700
|
+
/* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
|
|
3701
|
+
if (isENOENT(error)) {
|
|
3702
|
+
/* v8 ignore next -- native Windows coverage exercises this platform dispatch; POSIX reports ENOTDIR from open */
|
|
3703
|
+
if (process.platform === "win32") await this.assertLogParentAllowsAbsence(path);
|
|
3704
|
+
return false;
|
|
3705
|
+
}
|
|
3706
|
+
/* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */
|
|
3707
|
+
throw error;
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */
|
|
3711
|
+
async assertLogParentAllowsAbsence(path) {
|
|
3712
|
+
try {
|
|
3713
|
+
const parent = dirname(path);
|
|
3714
|
+
if ((await stat(parent)).isDirectory()) return;
|
|
3715
|
+
const error = /* @__PURE__ */ new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`);
|
|
3716
|
+
error.code = "ENOTDIR";
|
|
3717
|
+
error.path = parent;
|
|
3718
|
+
throw error;
|
|
3719
|
+
} catch (error) {
|
|
3720
|
+
if (isENOENT(error)) return;
|
|
3721
|
+
throw error;
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
};
|
|
3725
|
+
//#endregion
|
|
3726
|
+
export { JsonlCompressionSchema, JsonlSessionPersistence as default };
|