@devicerail/recorder 0.1.0
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 +201 -0
- package/README.md +111 -0
- package/dist/bundle-cli.d.ts +13 -0
- package/dist/bundle-cli.js +311 -0
- package/dist/canonical.d.ts +22 -0
- package/dist/canonical.js +174 -0
- package/dist/checkpoint.d.ts +28 -0
- package/dist/checkpoint.js +993 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +10 -0
- package/dist/event-log.d.ts +71 -0
- package/dist/event-log.js +860 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/recorder.d.ts +72 -0
- package/dist/recorder.js +607 -0
- package/dist/source-file.d.ts +25 -0
- package/dist/source-file.js +291 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.js +2 -0
- package/package.json +53 -0
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { RpcRemoteError } from "@devicerail/client";
|
|
3
|
+
import { exportAndValidateBundle } from "./bundle-cli.js";
|
|
4
|
+
import { appendRecorderCheckpointPage, commitRecorderCheckpoint, loadRecorderCheckpoint, } from "./checkpoint.js";
|
|
5
|
+
import { RecorderError } from "./errors.js";
|
|
6
|
+
import { EventLog } from "./event-log.js";
|
|
7
|
+
import { publishBundleSource, readBundleSource, validateBundleSourceBounds, } from "./source-file.js";
|
|
8
|
+
import { RECORDER_CHECKPOINT_FORMAT, RECORDER_CHECKPOINT_VERSION, } from "./types.js";
|
|
9
|
+
const RECORDER_EVENT_PAGE_SIZE = 1_000;
|
|
10
|
+
const SESSION_EXPORT_PAGE_FEATURE = "session.export.page.v1";
|
|
11
|
+
function isResponseFrameTooLarge(cause) {
|
|
12
|
+
return cause instanceof RpcRemoteError
|
|
13
|
+
&& cause.rpcError.data.code === "response_frame_too_large";
|
|
14
|
+
}
|
|
15
|
+
/** Public-protocol adapter; Session lifecycle remains owned by the host. */
|
|
16
|
+
export class DeviceRailRecorderEventSource {
|
|
17
|
+
#client;
|
|
18
|
+
constructor(client) {
|
|
19
|
+
this.#client = client;
|
|
20
|
+
}
|
|
21
|
+
async describe() {
|
|
22
|
+
return await this.#client.call("system.describe");
|
|
23
|
+
}
|
|
24
|
+
async listEvents(sessionId, afterSequence, limit) {
|
|
25
|
+
return await this.#client.call("events.list", {
|
|
26
|
+
sessionId,
|
|
27
|
+
...(afterSequence === null ? {} : { afterSequence }),
|
|
28
|
+
limit,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async exportSession(sessionId) {
|
|
32
|
+
return await this.#client.call("session.export", { sessionId });
|
|
33
|
+
}
|
|
34
|
+
async exportSessionPage(sessionId, afterSequence, limit) {
|
|
35
|
+
const params = {
|
|
36
|
+
sessionId,
|
|
37
|
+
...(afterSequence === null ? {} : { afterSequence }),
|
|
38
|
+
limit,
|
|
39
|
+
};
|
|
40
|
+
return await this.#client.call("session.export", params);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function throwIfAborted(signal) {
|
|
44
|
+
if (signal?.aborted) {
|
|
45
|
+
throw new RecorderError("operation_cancelled", "Recorder operation was cancelled");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function exactKeys(value, keys) {
|
|
49
|
+
return isDeepStrictEqual(Object.keys(value).sort(), [...keys].sort());
|
|
50
|
+
}
|
|
51
|
+
function objectRecord(value, location) {
|
|
52
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
53
|
+
throw new RecorderError("session_export_mismatch", `${location} must be an object`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function safeInteger(value) {
|
|
58
|
+
return (typeof value === "number" &&
|
|
59
|
+
Number.isSafeInteger(value) &&
|
|
60
|
+
!Object.is(value, -0) &&
|
|
61
|
+
value >= 0);
|
|
62
|
+
}
|
|
63
|
+
function validateProtocolVersion(value) {
|
|
64
|
+
const record = objectRecord(value, "selected protocol version");
|
|
65
|
+
if (!exactKeys(record, ["major", "minor"]) || !safeInteger(record.major) || !safeInteger(record.minor)) {
|
|
66
|
+
throw new RecorderError("session_export_mismatch", "selected protocol version must contain non-negative safe integer major/minor fields");
|
|
67
|
+
}
|
|
68
|
+
if (record.major !== 1 || record.minor > 5) {
|
|
69
|
+
throw new RecorderError("session_export_mismatch", "selected protocol version is unsupported by this Recorder");
|
|
70
|
+
}
|
|
71
|
+
return { major: record.major, minor: record.minor };
|
|
72
|
+
}
|
|
73
|
+
function validateEndedSession(value, events) {
|
|
74
|
+
const record = objectRecord(value, "session.export.session");
|
|
75
|
+
if (!exactKeys(record, [
|
|
76
|
+
"endedAtMs",
|
|
77
|
+
"eventCount",
|
|
78
|
+
"id",
|
|
79
|
+
"lastSequence",
|
|
80
|
+
"startedAtMs",
|
|
81
|
+
"state",
|
|
82
|
+
]) ||
|
|
83
|
+
typeof record.id !== "string" ||
|
|
84
|
+
record.state !== "ended" ||
|
|
85
|
+
!safeInteger(record.startedAtMs) ||
|
|
86
|
+
!safeInteger(record.endedAtMs) ||
|
|
87
|
+
!safeInteger(record.eventCount) ||
|
|
88
|
+
!safeInteger(record.lastSequence)) {
|
|
89
|
+
throw new RecorderError("session_export_mismatch", "session.export returned an invalid ended SessionInfo");
|
|
90
|
+
}
|
|
91
|
+
const first = events[0];
|
|
92
|
+
const last = events.at(-1);
|
|
93
|
+
if (!first ||
|
|
94
|
+
!last ||
|
|
95
|
+
record.id !== first.sessionId ||
|
|
96
|
+
record.eventCount !== events.length ||
|
|
97
|
+
record.lastSequence !== last.sequence ||
|
|
98
|
+
record.startedAtMs !== first.atMs ||
|
|
99
|
+
record.endedAtMs !== last.atMs) {
|
|
100
|
+
throw new RecorderError("session_export_mismatch", "SessionInfo does not match the recorded event lifecycle");
|
|
101
|
+
}
|
|
102
|
+
return structuredClone(record);
|
|
103
|
+
}
|
|
104
|
+
function validateSessionExport(value, sessionId, recordedEvents, eventProtocolVersion) {
|
|
105
|
+
const record = objectRecord(value, "session.export");
|
|
106
|
+
if (!exactKeys(record, ["events", "session"]) || !Array.isArray(record.events)) {
|
|
107
|
+
throw new RecorderError("session_export_mismatch", "session.export must contain exactly session and events");
|
|
108
|
+
}
|
|
109
|
+
const exportedLog = EventLog.replay(sessionId, record.events, eventProtocolVersion);
|
|
110
|
+
if (!exportedLog.terminal || exportedLog.openActionCount !== 0) {
|
|
111
|
+
throw new RecorderError("session_not_ended", "session.export is not terminal");
|
|
112
|
+
}
|
|
113
|
+
if (!isDeepStrictEqual(exportedLog.events, recordedEvents)) {
|
|
114
|
+
throw new RecorderError("session_export_mismatch", "session.export events differ from the durable Recorder log");
|
|
115
|
+
}
|
|
116
|
+
const session = validateEndedSession(record.session, exportedLog.events);
|
|
117
|
+
if (session.id !== sessionId) {
|
|
118
|
+
throw new RecorderError("session_mismatch", "session.export identifies another Session");
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
events: structuredClone(exportedLog.events),
|
|
122
|
+
session,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
async function loadAdaptivePage(options) {
|
|
126
|
+
let pageLimit = options.initialLimit;
|
|
127
|
+
while (true) {
|
|
128
|
+
throwIfAborted(options.signal);
|
|
129
|
+
try {
|
|
130
|
+
return { limit: pageLimit, value: await options.load(pageLimit) };
|
|
131
|
+
}
|
|
132
|
+
catch (cause) {
|
|
133
|
+
throwIfAborted(options.signal);
|
|
134
|
+
if (!isResponseFrameTooLarge(cause)) {
|
|
135
|
+
throw new RecorderError("upstream_unavailable", options.unavailableMessage, { cause });
|
|
136
|
+
}
|
|
137
|
+
if (pageLimit === 1) {
|
|
138
|
+
throw new RecorderError("event_too_large", options.tooLargeMessage, {
|
|
139
|
+
cause,
|
|
140
|
+
details: {
|
|
141
|
+
afterSequence: options.afterSequence,
|
|
142
|
+
pageLimit,
|
|
143
|
+
upstreamCode: cause.rpcError.data.code,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
pageLimit = Math.max(1, Math.floor(pageLimit / 2));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function validateSessionExportPage(value, recordedEvents, offset, pageLimit, priorSession) {
|
|
152
|
+
const record = objectRecord(value, "session.export page");
|
|
153
|
+
const hasNext = Object.hasOwn(record, "nextAfterSequence");
|
|
154
|
+
if (!exactKeys(record, hasNext ? ["events", "nextAfterSequence", "session"] : ["events", "session"])
|
|
155
|
+
|| !Array.isArray(record.events)) {
|
|
156
|
+
throw new RecorderError("session_export_mismatch", "session.export page must contain exactly session and events");
|
|
157
|
+
}
|
|
158
|
+
if (record.events.length > pageLimit) {
|
|
159
|
+
throw new RecorderError("session_export_mismatch", `session.export returned more than its ${pageLimit}-event page limit`);
|
|
160
|
+
}
|
|
161
|
+
const session = validateEndedSession(record.session, recordedEvents);
|
|
162
|
+
if (priorSession && !isDeepStrictEqual(session, priorSession)) {
|
|
163
|
+
throw new RecorderError("session_export_mismatch", "session.export SessionInfo changed between pages");
|
|
164
|
+
}
|
|
165
|
+
if (offset + record.events.length > recordedEvents.length) {
|
|
166
|
+
throw new RecorderError("session_export_mismatch", "session.export contains events beyond the durable Recorder log");
|
|
167
|
+
}
|
|
168
|
+
for (const [pageIndex, event] of record.events.entries()) {
|
|
169
|
+
if (!isDeepStrictEqual(event, recordedEvents[offset + pageIndex])) {
|
|
170
|
+
throw new RecorderError("session_export_mismatch", "session.export events differ from the durable Recorder log", { details: { sequence: offset + pageIndex + 1 } });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const nextAfterSequence = hasNext ? record.nextAfterSequence : null;
|
|
174
|
+
const remaining = offset + record.events.length < recordedEvents.length;
|
|
175
|
+
const lastPageEvent = record.events.at(-1);
|
|
176
|
+
if (remaining
|
|
177
|
+
? !safeInteger(nextAfterSequence)
|
|
178
|
+
|| nextAfterSequence < 1
|
|
179
|
+
|| nextAfterSequence !== lastPageEvent?.sequence
|
|
180
|
+
: hasNext) {
|
|
181
|
+
throw new RecorderError("session_export_mismatch", remaining
|
|
182
|
+
? "session.export must continue at the exact final sequence of a non-terminal page"
|
|
183
|
+
: "session.export final page must not contain nextAfterSequence");
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
eventCount: record.events.length,
|
|
187
|
+
nextAfterSequence: nextAfterSequence,
|
|
188
|
+
session,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function waitForPoll(milliseconds, signal) {
|
|
192
|
+
throwIfAborted(signal);
|
|
193
|
+
await new Promise((resolve, reject) => {
|
|
194
|
+
let settled = false;
|
|
195
|
+
const finish = (callback) => {
|
|
196
|
+
if (settled) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
settled = true;
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
signal?.removeEventListener("abort", abortListener);
|
|
202
|
+
callback();
|
|
203
|
+
};
|
|
204
|
+
const abortListener = () => {
|
|
205
|
+
finish(() => reject(new RecorderError("operation_cancelled", "Recorder operation was cancelled")));
|
|
206
|
+
};
|
|
207
|
+
const timer = setTimeout(() => finish(resolve), milliseconds);
|
|
208
|
+
signal?.addEventListener("abort", abortListener, { once: true });
|
|
209
|
+
if (signal?.aborted) {
|
|
210
|
+
abortListener();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async function awaitSealOperation(operation, signal) {
|
|
215
|
+
throwIfAborted(signal);
|
|
216
|
+
if (!signal) {
|
|
217
|
+
return await operation;
|
|
218
|
+
}
|
|
219
|
+
return await new Promise((resolve, reject) => {
|
|
220
|
+
let settled = false;
|
|
221
|
+
const finish = (callback) => {
|
|
222
|
+
if (settled) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
settled = true;
|
|
226
|
+
signal.removeEventListener("abort", abortListener);
|
|
227
|
+
callback();
|
|
228
|
+
};
|
|
229
|
+
const abortListener = () => {
|
|
230
|
+
finish(() => reject(new RecorderError("operation_cancelled", "Recorder operation was cancelled")));
|
|
231
|
+
};
|
|
232
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
233
|
+
operation.then((source) => finish(() => resolve(source)), (error) => finish(() => reject(error)));
|
|
234
|
+
if (signal.aborted) {
|
|
235
|
+
abortListener();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** Durable, resumable consumer of one public Session event stream. */
|
|
240
|
+
export class ExecutionRecorder {
|
|
241
|
+
#checkpointPath;
|
|
242
|
+
#eventSource;
|
|
243
|
+
#supportsPaginatedSessionExport;
|
|
244
|
+
#checkpoint;
|
|
245
|
+
#log;
|
|
246
|
+
#sealInFlight;
|
|
247
|
+
constructor(eventSource, checkpointPath, checkpoint, supportsPaginatedSessionExport) {
|
|
248
|
+
this.#eventSource = eventSource;
|
|
249
|
+
this.#checkpointPath = checkpointPath;
|
|
250
|
+
this.#checkpoint = checkpoint;
|
|
251
|
+
this.#supportsPaginatedSessionExport = supportsPaginatedSessionExport;
|
|
252
|
+
this.#log = EventLog.replay(checkpoint.sessionId, checkpoint.events, checkpoint.eventProtocolVersion);
|
|
253
|
+
if (checkpoint.phase !== "recording" && !this.#log.terminal) {
|
|
254
|
+
throw new RecorderError("checkpoint_corrupt", "sealed checkpoint is not terminal");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
static async open(options) {
|
|
258
|
+
throwIfAborted(options.signal);
|
|
259
|
+
const eventSource = options.eventSource ?? new DeviceRailRecorderEventSource(options.client);
|
|
260
|
+
let description;
|
|
261
|
+
try {
|
|
262
|
+
description = await eventSource.describe();
|
|
263
|
+
}
|
|
264
|
+
catch (cause) {
|
|
265
|
+
throw new RecorderError("upstream_unavailable", "Recorder could not describe the daemon", {
|
|
266
|
+
cause,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
throwIfAborted(options.signal);
|
|
270
|
+
const eventProtocolVersion = validateProtocolVersion(description.connection.protocol.selected);
|
|
271
|
+
const loaded = await loadRecorderCheckpoint(options.checkpointPath);
|
|
272
|
+
let checkpoint;
|
|
273
|
+
if (loaded) {
|
|
274
|
+
if (loaded.sessionId !== options.sessionId) {
|
|
275
|
+
throw new RecorderError("session_mismatch", "checkpoint belongs to another Session");
|
|
276
|
+
}
|
|
277
|
+
if (!isDeepStrictEqual(loaded.eventProtocolVersion, eventProtocolVersion)) {
|
|
278
|
+
throw new RecorderError("checkpoint_conflict", "checkpoint protocol version differs from the live connection");
|
|
279
|
+
}
|
|
280
|
+
checkpoint = loaded;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const initial = {
|
|
284
|
+
format: RECORDER_CHECKPOINT_FORMAT,
|
|
285
|
+
version: RECORDER_CHECKPOINT_VERSION,
|
|
286
|
+
revision: 1,
|
|
287
|
+
phase: "recording",
|
|
288
|
+
sessionId: options.sessionId,
|
|
289
|
+
eventProtocolVersion,
|
|
290
|
+
events: [],
|
|
291
|
+
};
|
|
292
|
+
checkpoint = await commitRecorderCheckpoint(options.checkpointPath, 0, initial, {
|
|
293
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const supportsPaginatedSessionExport = description.connection.features.enabled.includes(SESSION_EXPORT_PAGE_FEATURE)
|
|
297
|
+
&& typeof eventSource.exportSessionPage === "function";
|
|
298
|
+
return new ExecutionRecorder(eventSource, options.checkpointPath, checkpoint, supportsPaginatedSessionExport);
|
|
299
|
+
}
|
|
300
|
+
/** Reopen a sealed/completed checkpoint after the daemon has stopped. */
|
|
301
|
+
static async openOffline(options) {
|
|
302
|
+
const checkpoint = await loadRecorderCheckpoint(options.checkpointPath);
|
|
303
|
+
if (!checkpoint) {
|
|
304
|
+
throw new RecorderError("checkpoint_corrupt", "Recorder checkpoint does not exist");
|
|
305
|
+
}
|
|
306
|
+
if (checkpoint.phase === "recording") {
|
|
307
|
+
throw new RecorderError("upstream_unavailable", "an active recording requires its original live daemon connection");
|
|
308
|
+
}
|
|
309
|
+
return new ExecutionRecorder(undefined, options.checkpointPath, checkpoint, false);
|
|
310
|
+
}
|
|
311
|
+
get phase() {
|
|
312
|
+
return this.#checkpoint.phase;
|
|
313
|
+
}
|
|
314
|
+
get lastSequence() {
|
|
315
|
+
return this.#log.lastSequence;
|
|
316
|
+
}
|
|
317
|
+
get checkpoint() {
|
|
318
|
+
const checkpoint = this.#checkpoint.phase === "recording"
|
|
319
|
+
? { ...this.#checkpoint, events: this.#log.events }
|
|
320
|
+
: this.#checkpoint;
|
|
321
|
+
return structuredClone(checkpoint);
|
|
322
|
+
}
|
|
323
|
+
bundleSource() {
|
|
324
|
+
if (this.#checkpoint.phase === "recording") {
|
|
325
|
+
throw new RecorderError("session_not_ended", "recording has not been sealed");
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
eventProtocolVersion: structuredClone(this.#checkpoint.eventProtocolVersion),
|
|
329
|
+
sessionExport: {
|
|
330
|
+
events: structuredClone(this.#checkpoint.events),
|
|
331
|
+
session: structuredClone(this.#checkpoint.session),
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
async captureOnce(options = {}) {
|
|
336
|
+
throwIfAborted(options.signal);
|
|
337
|
+
if (this.#checkpoint.phase !== "recording") {
|
|
338
|
+
return {
|
|
339
|
+
accepted: 0,
|
|
340
|
+
duplicates: 0,
|
|
341
|
+
lastSequence: this.#log.lastSequence,
|
|
342
|
+
phase: this.#checkpoint.phase,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (this.#log.terminal) {
|
|
346
|
+
await this.seal(options);
|
|
347
|
+
return {
|
|
348
|
+
accepted: 0,
|
|
349
|
+
duplicates: 0,
|
|
350
|
+
lastSequence: this.#log.lastSequence,
|
|
351
|
+
phase: this.#checkpoint.phase,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (!this.#eventSource) {
|
|
355
|
+
throw new RecorderError("upstream_unavailable", "Recorder has no live event source");
|
|
356
|
+
}
|
|
357
|
+
let acceptedTotal = 0;
|
|
358
|
+
let duplicateTotal = 0;
|
|
359
|
+
let pageLimit = RECORDER_EVENT_PAGE_SIZE;
|
|
360
|
+
while (!this.#log.terminal) {
|
|
361
|
+
const afterSequence = this.#log.lastSequence;
|
|
362
|
+
const page = await loadAdaptivePage({
|
|
363
|
+
afterSequence,
|
|
364
|
+
initialLimit: pageLimit,
|
|
365
|
+
load: async (limit) => await this.#eventSource.listEvents(this.#checkpoint.sessionId, afterSequence, limit),
|
|
366
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
367
|
+
tooLargeMessage: "one Session event cannot fit in the bounded events.list response",
|
|
368
|
+
unavailableMessage: "Recorder could not list Session events",
|
|
369
|
+
});
|
|
370
|
+
pageLimit = page.limit;
|
|
371
|
+
const values = page.value;
|
|
372
|
+
throwIfAborted(options.signal);
|
|
373
|
+
if (!Array.isArray(values) || values.length > pageLimit) {
|
|
374
|
+
throw new RecorderError("invalid_event", `events.list result must be an array of at most ${pageLimit} events`);
|
|
375
|
+
}
|
|
376
|
+
const prepared = this.#log.prepareBatch(values);
|
|
377
|
+
const accepted = prepared.result;
|
|
378
|
+
acceptedTotal += accepted.accepted;
|
|
379
|
+
duplicateTotal += accepted.duplicates;
|
|
380
|
+
if (accepted.accepted > 0) {
|
|
381
|
+
const appended = await appendRecorderCheckpointPage(this.#checkpointPath, this.#checkpoint.revision, this.#checkpoint, prepared.acceptedEvents, { ...(options.signal ? { signal: options.signal } : {}) });
|
|
382
|
+
try {
|
|
383
|
+
this.#log.commitPreparedBatch(prepared);
|
|
384
|
+
}
|
|
385
|
+
catch (cause) {
|
|
386
|
+
const recovered = await loadRecorderCheckpoint(this.#checkpointPath);
|
|
387
|
+
if (recovered === null) {
|
|
388
|
+
throw cause;
|
|
389
|
+
}
|
|
390
|
+
this.#checkpoint = recovered;
|
|
391
|
+
this.#log = EventLog.replay(recovered.sessionId, recovered.events, recovered.eventProtocolVersion);
|
|
392
|
+
throw cause;
|
|
393
|
+
}
|
|
394
|
+
// Recording pages live in the append-only journal. Avoid materializing
|
|
395
|
+
// a growing checkpoint snapshot until a caller requests it or sealing
|
|
396
|
+
// performs the one compaction write.
|
|
397
|
+
this.#checkpoint = {
|
|
398
|
+
...this.#checkpoint,
|
|
399
|
+
revision: appended.revision,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (values.length < pageLimit || this.#log.terminal) {
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (this.#log.lastSequence === afterSequence) {
|
|
406
|
+
throw new RecorderError("invalid_event", "a full events.list page did not advance the Recorder sequence");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (this.#log.terminal) {
|
|
410
|
+
await this.seal(options);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
accepted: acceptedTotal,
|
|
414
|
+
duplicates: duplicateTotal,
|
|
415
|
+
lastSequence: this.#log.lastSequence,
|
|
416
|
+
phase: this.#checkpoint.phase,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async captureUntilSealed(options = {}) {
|
|
420
|
+
const pollIntervalMs = options.pollIntervalMs ?? 50;
|
|
421
|
+
if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 1 || pollIntervalMs > 60_000) {
|
|
422
|
+
throw new RangeError("pollIntervalMs must be a safe integer between 1 and 60000");
|
|
423
|
+
}
|
|
424
|
+
while (this.#checkpoint.phase === "recording") {
|
|
425
|
+
const result = await this.captureOnce(options);
|
|
426
|
+
if (result.phase === "recording" && result.accepted === 0) {
|
|
427
|
+
await waitForPoll(pollIntervalMs, options.signal);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return this.bundleSource();
|
|
431
|
+
}
|
|
432
|
+
async #validatePaginatedSessionExport(exportSessionPage, recording, signal) {
|
|
433
|
+
let afterSequence = null;
|
|
434
|
+
let offset = 0;
|
|
435
|
+
let pageLimit = RECORDER_EVENT_PAGE_SIZE;
|
|
436
|
+
let authoritativeSession;
|
|
437
|
+
while (true) {
|
|
438
|
+
const page = await loadAdaptivePage({
|
|
439
|
+
afterSequence,
|
|
440
|
+
initialLimit: pageLimit,
|
|
441
|
+
load: async (limit) => await exportSessionPage.call(this.#eventSource, recording.sessionId, afterSequence, limit),
|
|
442
|
+
...(signal ? { signal } : {}),
|
|
443
|
+
tooLargeMessage: "one Session event cannot fit in the bounded session.export response",
|
|
444
|
+
unavailableMessage: "Recorder could not export the Session page",
|
|
445
|
+
});
|
|
446
|
+
pageLimit = page.limit;
|
|
447
|
+
throwIfAborted(signal);
|
|
448
|
+
const validated = validateSessionExportPage(page.value, recording.events, offset, pageLimit, authoritativeSession);
|
|
449
|
+
authoritativeSession ??= validated.session;
|
|
450
|
+
offset += validated.eventCount;
|
|
451
|
+
if (validated.nextAfterSequence === null) {
|
|
452
|
+
if (offset !== recording.events.length) {
|
|
453
|
+
throw new RecorderError("session_export_mismatch", "session.export ended before the durable Recorder log");
|
|
454
|
+
}
|
|
455
|
+
return authoritativeSession;
|
|
456
|
+
}
|
|
457
|
+
afterSequence = validated.nextAfterSequence;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async seal(options = {}) {
|
|
461
|
+
let mayRetryForeignFailure = true;
|
|
462
|
+
while (true) {
|
|
463
|
+
throwIfAborted(options.signal);
|
|
464
|
+
let operation = this.#sealInFlight;
|
|
465
|
+
const ownsOperation = operation === undefined;
|
|
466
|
+
if (!operation) {
|
|
467
|
+
operation = this.#sealOnce(options);
|
|
468
|
+
this.#sealInFlight = operation;
|
|
469
|
+
const clear = () => {
|
|
470
|
+
if (this.#sealInFlight === operation) {
|
|
471
|
+
this.#sealInFlight = undefined;
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
operation.then(clear, clear);
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
return await awaitSealOperation(operation, options.signal);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
throwIfAborted(options.signal);
|
|
481
|
+
if (!ownsOperation
|
|
482
|
+
&& mayRetryForeignFailure
|
|
483
|
+
&& this.#checkpoint.phase === "recording") {
|
|
484
|
+
mayRetryForeignFailure = false;
|
|
485
|
+
if (this.#sealInFlight === operation) {
|
|
486
|
+
this.#sealInFlight = undefined;
|
|
487
|
+
}
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async #sealOnce(options) {
|
|
495
|
+
if (this.#checkpoint.phase !== "recording") {
|
|
496
|
+
return this.bundleSource();
|
|
497
|
+
}
|
|
498
|
+
const recording = {
|
|
499
|
+
...this.#checkpoint,
|
|
500
|
+
events: this.#log.events,
|
|
501
|
+
};
|
|
502
|
+
if (!this.#log.terminal || this.#log.openActionCount !== 0) {
|
|
503
|
+
throw new RecorderError("session_not_ended", "Session event stream is not terminal");
|
|
504
|
+
}
|
|
505
|
+
if (!this.#eventSource) {
|
|
506
|
+
throw new RecorderError("upstream_unavailable", "Recorder has no live event source");
|
|
507
|
+
}
|
|
508
|
+
let authoritativeSession;
|
|
509
|
+
const exportSessionPage = this.#eventSource.exportSessionPage;
|
|
510
|
+
if (this.#supportsPaginatedSessionExport && exportSessionPage) {
|
|
511
|
+
authoritativeSession = await this.#validatePaginatedSessionExport(exportSessionPage, recording, options.signal);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
let value;
|
|
515
|
+
try {
|
|
516
|
+
value = await this.#eventSource.exportSession(recording.sessionId);
|
|
517
|
+
}
|
|
518
|
+
catch (cause) {
|
|
519
|
+
throwIfAborted(options.signal);
|
|
520
|
+
throw new RecorderError("upstream_unavailable", "Recorder could not export the Session", {
|
|
521
|
+
cause,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
throwIfAborted(options.signal);
|
|
525
|
+
authoritativeSession = validateSessionExport(value, recording.sessionId, recording.events, recording.eventProtocolVersion).session;
|
|
526
|
+
}
|
|
527
|
+
const source = validateBundleSourceBounds({
|
|
528
|
+
eventProtocolVersion: recording.eventProtocolVersion,
|
|
529
|
+
sessionExport: {
|
|
530
|
+
events: recording.events,
|
|
531
|
+
session: authoritativeSession,
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
if (this.#checkpoint.phase !== "recording"
|
|
535
|
+
|| this.#checkpoint.revision !== recording.revision) {
|
|
536
|
+
if (this.#checkpoint.phase !== "recording"
|
|
537
|
+
&& isDeepStrictEqual(this.bundleSource(), source)) {
|
|
538
|
+
return this.bundleSource();
|
|
539
|
+
}
|
|
540
|
+
throw new RecorderError("checkpoint_conflict", "Recorder checkpoint changed while the Session export was being verified");
|
|
541
|
+
}
|
|
542
|
+
const next = {
|
|
543
|
+
format: RECORDER_CHECKPOINT_FORMAT,
|
|
544
|
+
version: RECORDER_CHECKPOINT_VERSION,
|
|
545
|
+
revision: recording.revision + 1,
|
|
546
|
+
phase: "sealed",
|
|
547
|
+
sessionId: recording.sessionId,
|
|
548
|
+
eventProtocolVersion: recording.eventProtocolVersion,
|
|
549
|
+
events: recording.events,
|
|
550
|
+
session: authoritativeSession,
|
|
551
|
+
};
|
|
552
|
+
this.#checkpoint = await commitRecorderCheckpoint(this.#checkpointPath, recording.revision, next, { ...(options.signal ? { signal: options.signal } : {}) });
|
|
553
|
+
return this.bundleSource();
|
|
554
|
+
}
|
|
555
|
+
async publishSource(path, options = {}) {
|
|
556
|
+
const source = this.bundleSource();
|
|
557
|
+
try {
|
|
558
|
+
await publishBundleSource(path, source, {
|
|
559
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
if (!(error instanceof RecorderError) || error.code !== "source_conflict") {
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
const existing = await readBundleSource(path);
|
|
567
|
+
if (!isDeepStrictEqual(existing, source)) {
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async finalize(options) {
|
|
573
|
+
const completedReceipt = this.#checkpoint.phase === "completed"
|
|
574
|
+
? structuredClone(this.#checkpoint.bundle)
|
|
575
|
+
: undefined;
|
|
576
|
+
const source = this.bundleSource();
|
|
577
|
+
await this.publishSource(options.sourcePath, {
|
|
578
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
579
|
+
});
|
|
580
|
+
const receipt = await exportAndValidateBundle({
|
|
581
|
+
...options,
|
|
582
|
+
source,
|
|
583
|
+
});
|
|
584
|
+
if (completedReceipt) {
|
|
585
|
+
if (!isDeepStrictEqual(receipt, completedReceipt)) {
|
|
586
|
+
throw new RecorderError("bundle_summary_mismatch", "validated Bundle differs from the completed checkpoint receipt");
|
|
587
|
+
}
|
|
588
|
+
return structuredClone(receipt);
|
|
589
|
+
}
|
|
590
|
+
if (this.#checkpoint.phase !== "sealed") {
|
|
591
|
+
throw new RecorderError("checkpoint_conflict", "Recorder checkpoint changed during finalize");
|
|
592
|
+
}
|
|
593
|
+
const next = {
|
|
594
|
+
format: RECORDER_CHECKPOINT_FORMAT,
|
|
595
|
+
version: RECORDER_CHECKPOINT_VERSION,
|
|
596
|
+
revision: this.#checkpoint.revision + 1,
|
|
597
|
+
phase: "completed",
|
|
598
|
+
sessionId: this.#checkpoint.sessionId,
|
|
599
|
+
eventProtocolVersion: this.#checkpoint.eventProtocolVersion,
|
|
600
|
+
events: this.#checkpoint.events,
|
|
601
|
+
session: this.#checkpoint.session,
|
|
602
|
+
bundle: receipt,
|
|
603
|
+
};
|
|
604
|
+
this.#checkpoint = await commitRecorderCheckpoint(this.#checkpointPath, this.#checkpoint.revision, next, { ...(options.signal ? { signal: options.signal } : {}) });
|
|
605
|
+
return structuredClone(receipt);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ProtocolVersion, SessionExport, SessionInfo, TestEvent } from "@devicerail/protocol";
|
|
2
|
+
export declare const BUNDLE_SOURCE_MAX_BYTES: number;
|
|
3
|
+
/** Strict local input consumed by the Rust Session Bundle CLI. */
|
|
4
|
+
export interface BundleSourceFile {
|
|
5
|
+
readonly eventProtocolVersion: ProtocolVersion;
|
|
6
|
+
readonly sessionExport: SessionExport;
|
|
7
|
+
}
|
|
8
|
+
export interface BundleSourceFileOptions {
|
|
9
|
+
readonly maxBytes?: number;
|
|
10
|
+
readonly signal?: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
/** Strictly validate and detach one ended BundleSource. */
|
|
13
|
+
export declare function validateBundleSourceFile(value: unknown): BundleSourceFile;
|
|
14
|
+
/** Validate that a strict BundleSource also fits its publication contract. */
|
|
15
|
+
export declare function validateBundleSourceBounds(value: unknown): BundleSourceFile;
|
|
16
|
+
/** Read a strict canonical BundleSource; Unix additionally enforces owner-only metadata. */
|
|
17
|
+
export declare function readBundleSource(path: string, options?: BundleSourceFileOptions): Promise<BundleSourceFile>;
|
|
18
|
+
/**
|
|
19
|
+
* Atomically publish a BundleSource without replacing any existing path.
|
|
20
|
+
* Unix creates it owner-only; Windows relies on the caller's parent ACL. The
|
|
21
|
+
* hard-link operation is the publication linearization point.
|
|
22
|
+
*/
|
|
23
|
+
export declare function publishBundleSource(path: string, source: BundleSourceFile, options?: BundleSourceFileOptions): Promise<void>;
|
|
24
|
+
/** Construct the exact local source expected by the Bundle CLI. */
|
|
25
|
+
export declare function bundleSourceFromEndedSession(eventProtocolVersion: ProtocolVersion, session: SessionInfo, events: readonly TestEvent[]): BundleSourceFile;
|