@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.
@@ -0,0 +1,860 @@
1
+ import { RecorderError } from "./errors.js";
2
+ const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
3
+ const MAX_JSON_DEPTH = 128;
4
+ const MAX_JSON_NODES = 1_000_000;
5
+ const MAX_VERDICT_SUMMARY_LENGTH = 16_384;
6
+ const MAX_VERDICT_EVIDENCE_REFERENCES = 64;
7
+ const UINT32_MAX = 4_294_967_295;
8
+ const UI_SNAPSHOT_MEDIA_TYPE = "application/vnd.devicerail.ui-tree+json;version=1";
9
+ const PREPARED_STATE = Symbol("prepared event-log state");
10
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
11
+ const ABSENT = Symbol("absent correlation");
12
+ class OverlaySet {
13
+ #base;
14
+ added = new Set();
15
+ constructor(base) {
16
+ this.#base = base;
17
+ }
18
+ has(value) {
19
+ return this.added.has(value) || this.#base.has(value);
20
+ }
21
+ add(value) {
22
+ this.added.add(value);
23
+ }
24
+ }
25
+ function invalidEvent(location, message) {
26
+ throw new RecorderError("invalid_event", `${location}: ${message}`, {
27
+ details: { location },
28
+ });
29
+ }
30
+ function recordAt(value, location) {
31
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
32
+ return invalidEvent(location, "must be an object");
33
+ }
34
+ const prototype = Object.getPrototypeOf(value);
35
+ if (prototype !== Object.prototype && prototype !== null) {
36
+ return invalidEvent(location, "must be a plain JSON object");
37
+ }
38
+ for (const key of Reflect.ownKeys(value)) {
39
+ if (typeof key !== "string") {
40
+ return invalidEvent(location, "must not contain symbol keys");
41
+ }
42
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
43
+ if (!descriptor?.enumerable || !("value" in descriptor)) {
44
+ return invalidEvent(location, `field ${key} must be an enumerable data property`);
45
+ }
46
+ }
47
+ return value;
48
+ }
49
+ function exactKeys(value, required, optional, location) {
50
+ const allowed = new Set([...required, ...optional]);
51
+ for (const key of required) {
52
+ if (!Object.hasOwn(value, key)) {
53
+ invalidEvent(location, `is missing required field ${key}`);
54
+ }
55
+ }
56
+ for (const key of Object.keys(value)) {
57
+ if (!allowed.has(key)) {
58
+ invalidEvent(location, `contains unknown field ${key}`);
59
+ }
60
+ }
61
+ }
62
+ function stringAt(value, location) {
63
+ if (typeof value !== "string") {
64
+ return invalidEvent(location, "must be a string");
65
+ }
66
+ return value;
67
+ }
68
+ function boundedStringAt(value, location, maximumLength) {
69
+ const result = stringAt(value, location);
70
+ if (result.length === 0 || [...result].length > maximumLength) {
71
+ return invalidEvent(location, `must contain 1..${String(maximumLength)} Unicode code points`);
72
+ }
73
+ return result;
74
+ }
75
+ function booleanAt(value, location) {
76
+ if (typeof value !== "boolean") {
77
+ return invalidEvent(location, "must be a boolean");
78
+ }
79
+ return value;
80
+ }
81
+ function safeUnsignedIntegerAt(value, location, minimum = 0, maximum = MAX_SAFE_INTEGER) {
82
+ if (typeof value !== "number" ||
83
+ !Number.isSafeInteger(value) ||
84
+ Object.is(value, -0) ||
85
+ value < minimum ||
86
+ value > maximum) {
87
+ return invalidEvent(location, `must be an integer between ${String(minimum)} and ${String(maximum)}`);
88
+ }
89
+ return value;
90
+ }
91
+ function finiteNumberAt(value, location) {
92
+ if (typeof value !== "number" || !Number.isFinite(value)) {
93
+ return invalidEvent(location, "must be a finite number");
94
+ }
95
+ return value;
96
+ }
97
+ function uuidAt(value, location) {
98
+ const uuid = stringAt(value, location);
99
+ if (!UUID_PATTERN.test(uuid)) {
100
+ return invalidEvent(location, "must be a canonical lowercase UUID");
101
+ }
102
+ return uuid;
103
+ }
104
+ function enumAt(value, allowed, location) {
105
+ if (typeof value !== "string" || !allowed.includes(value)) {
106
+ return invalidEvent(location, `must be one of ${allowed.join(", ")}`);
107
+ }
108
+ return value;
109
+ }
110
+ function arrayAt(value, location) {
111
+ if (!Array.isArray(value)) {
112
+ return invalidEvent(location, "must be an array");
113
+ }
114
+ for (let index = 0; index < value.length; index += 1) {
115
+ if (!Object.hasOwn(value, index)) {
116
+ invalidEvent(`${location}[${String(index)}]`, "array entries must not be sparse");
117
+ }
118
+ }
119
+ for (const key of Reflect.ownKeys(value)) {
120
+ if (key === "length") {
121
+ continue;
122
+ }
123
+ if (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key)) {
124
+ invalidEvent(location, "must not contain non-index properties");
125
+ }
126
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
127
+ if (!descriptor?.enumerable || !("value" in descriptor)) {
128
+ invalidEvent(location, `entry ${key} must be an enumerable data property`);
129
+ }
130
+ }
131
+ return value;
132
+ }
133
+ function validateRpcId(value, location) {
134
+ if (typeof value === "string") {
135
+ return;
136
+ }
137
+ safeUnsignedIntegerAt(value, location);
138
+ }
139
+ function validateAssetRef(value, location) {
140
+ const reference = recordAt(value, location);
141
+ exactKeys(reference, ["id", "mediaType", "uri", "sha256"], [], location);
142
+ stringAt(reference.id, `${location}.id`);
143
+ stringAt(reference.mediaType, `${location}.mediaType`);
144
+ stringAt(reference.uri, `${location}.uri`);
145
+ if (reference.sha256 !== null) {
146
+ stringAt(reference.sha256, `${location}.sha256`);
147
+ }
148
+ }
149
+ function validateViewport(value, location) {
150
+ const viewport = recordAt(value, location);
151
+ exactKeys(viewport, ["width", "height", "scaleFactor"], [], location);
152
+ safeUnsignedIntegerAt(viewport.width, `${location}.width`, 0, UINT32_MAX);
153
+ safeUnsignedIntegerAt(viewport.height, `${location}.height`, 0, UINT32_MAX);
154
+ finiteNumberAt(viewport.scaleFactor, `${location}.scaleFactor`);
155
+ }
156
+ function validateUiContext(value, location) {
157
+ const context = recordAt(value, location);
158
+ exactKeys(context, ["contextKind", "contextId", "documentEpoch"], [], location);
159
+ const contextKind = enumAt(context.contextKind, ["native", "web"], `${location}.contextKind`);
160
+ boundedStringAt(context.contextId, `${location}.contextId`, 4_096);
161
+ boundedStringAt(context.documentEpoch, `${location}.documentEpoch`, 4_096);
162
+ return contextKind;
163
+ }
164
+ function validateUiSnapshotRef(value, location) {
165
+ const snapshot = recordAt(value, location);
166
+ exactKeys(snapshot, ["formatVersion", "context", "nodeCount", "byteLength", "evidence"], [], location);
167
+ safeUnsignedIntegerAt(snapshot.formatVersion, `${location}.formatVersion`, 1, 1);
168
+ validateUiContext(snapshot.context, `${location}.context`);
169
+ safeUnsignedIntegerAt(snapshot.nodeCount, `${location}.nodeCount`, 1, 10_000);
170
+ safeUnsignedIntegerAt(snapshot.byteLength, `${location}.byteLength`, 1, 786_432);
171
+ validateAssetRef(snapshot.evidence, `${location}.evidence`);
172
+ const evidence = recordAt(snapshot.evidence, `${location}.evidence`);
173
+ if (evidence.mediaType !== UI_SNAPSHOT_MEDIA_TYPE) {
174
+ invalidEvent(`${location}.evidence.mediaType`, "must identify a v1 DeviceRail UI Tree");
175
+ }
176
+ }
177
+ function validateActionExecution(value, location) {
178
+ const execution = recordAt(value, location);
179
+ const mode = stringAt(execution.mode, `${location}.mode`);
180
+ switch (mode) {
181
+ case "nativeSemantic":
182
+ case "webSemantic": {
183
+ exactKeys(execution, ["mode", "context"], [], location);
184
+ const contextKind = validateUiContext(execution.context, `${location}.context`);
185
+ const expected = mode === "nativeSemantic" ? "native" : "web";
186
+ if (contextKind !== expected) {
187
+ invalidEvent(`${location}.context.contextKind`, "must match the execution mode");
188
+ }
189
+ return;
190
+ }
191
+ case "coordinateFallback":
192
+ exactKeys(execution, ["mode", "context", "fallbackReason"], [], location);
193
+ validateUiContext(execution.context, `${location}.context`);
194
+ enumAt(execution.fallbackReason, ["semanticInteractionUnavailable", "platformLimitation"], `${location}.fallbackReason`);
195
+ return;
196
+ default:
197
+ invalidEvent(`${location}.mode`, "has an unknown execution mode");
198
+ }
199
+ }
200
+ function validateObservation(value, location) {
201
+ const observation = recordAt(value, location);
202
+ exactKeys(observation, ["id", "deviceId", "capturedAtMs", "viewport", "screenshot", "metadata"], ["screenshotOmission", "uiSnapshot", "uiSnapshotOmission"], location);
203
+ uuidAt(observation.id, `${location}.id`);
204
+ stringAt(observation.deviceId, `${location}.deviceId`);
205
+ safeUnsignedIntegerAt(observation.capturedAtMs, `${location}.capturedAtMs`);
206
+ validateViewport(observation.viewport, `${location}.viewport`);
207
+ const hasScreenshot = observation.screenshot !== null;
208
+ if (hasScreenshot) {
209
+ validateAssetRef(observation.screenshot, `${location}.screenshot`);
210
+ }
211
+ const hasOmission = Object.hasOwn(observation, "screenshotOmission");
212
+ if (hasOmission) {
213
+ enumAt(observation.screenshotOmission, ["policy", "protectedAction"], `${location}.screenshotOmission`);
214
+ }
215
+ if (hasScreenshot && hasOmission) {
216
+ invalidEvent(location, "screenshot and screenshotOmission are mutually exclusive");
217
+ }
218
+ const hasUiSnapshot = Object.hasOwn(observation, "uiSnapshot");
219
+ if (hasUiSnapshot) {
220
+ validateUiSnapshotRef(observation.uiSnapshot, `${location}.uiSnapshot`);
221
+ }
222
+ const hasUiOmission = Object.hasOwn(observation, "uiSnapshotOmission");
223
+ if (hasUiOmission) {
224
+ enumAt(observation.uiSnapshotOmission, ["driverUnsupported", "policy", "protectedAction"], `${location}.uiSnapshotOmission`);
225
+ }
226
+ if (hasUiSnapshot && hasUiOmission) {
227
+ invalidEvent(location, "uiSnapshot and uiSnapshotOmission are mutually exclusive");
228
+ }
229
+ recordAt(observation.metadata, `${location}.metadata`);
230
+ }
231
+ function validateErrorInfo(value, location) {
232
+ const error = recordAt(value, location);
233
+ exactKeys(error, ["code", "message", "retryable", "details"], [], location);
234
+ stringAt(error.code, `${location}.code`);
235
+ stringAt(error.message, `${location}.message`);
236
+ booleanAt(error.retryable, `${location}.retryable`);
237
+ }
238
+ function validateVerdict(value, location) {
239
+ const verdict = recordAt(value, location);
240
+ exactKeys(verdict, ["status", "summary", "evidence"], [], location);
241
+ enumAt(verdict.status, ["pass", "fail", "unknown"], `${location}.status`);
242
+ const summary = boundedStringAt(verdict.summary, `${location}.summary`, MAX_VERDICT_SUMMARY_LENGTH);
243
+ if (summary.trim().length === 0) {
244
+ invalidEvent(`${location}.summary`, "must not be blank");
245
+ }
246
+ const evidence = arrayAt(verdict.evidence, `${location}.evidence`);
247
+ if (evidence.length > MAX_VERDICT_EVIDENCE_REFERENCES) {
248
+ invalidEvent(`${location}.evidence`, `must contain at most ${String(MAX_VERDICT_EVIDENCE_REFERENCES)} references`);
249
+ }
250
+ for (const [index, reference] of evidence.entries()) {
251
+ validateAssetRef(reference, `${location}.evidence[${String(index)}]`);
252
+ }
253
+ }
254
+ function validateActionResult(value, location) {
255
+ const result = recordAt(value, location);
256
+ exactKeys(result, ["callId", "startedAtMs", "finishedAtMs", "output", "before", "after", "evidence"], ["execution"], location);
257
+ uuidAt(result.callId, `${location}.callId`);
258
+ const startedAtMs = safeUnsignedIntegerAt(result.startedAtMs, `${location}.startedAtMs`);
259
+ const finishedAtMs = safeUnsignedIntegerAt(result.finishedAtMs, `${location}.finishedAtMs`);
260
+ if (finishedAtMs < startedAtMs) {
261
+ invalidEvent(location, "finishedAtMs must not precede startedAtMs");
262
+ }
263
+ for (const key of ["before", "after"]) {
264
+ if (result[key] !== null) {
265
+ validateObservation(result[key], `${location}.${key}`);
266
+ }
267
+ }
268
+ for (const [index, reference] of arrayAt(result.evidence, `${location}.evidence`).entries()) {
269
+ validateAssetRef(reference, `${location}.evidence[${String(index)}]`);
270
+ }
271
+ if (Object.hasOwn(result, "execution")) {
272
+ validateActionExecution(result.execution, `${location}.execution`);
273
+ }
274
+ }
275
+ function validateActionOutcome(value, location) {
276
+ const outcome = recordAt(value, location);
277
+ const kind = stringAt(outcome.outcome, `${location}.outcome`);
278
+ switch (kind) {
279
+ case "succeeded":
280
+ exactKeys(outcome, ["outcome", "result"], [], location);
281
+ validateActionResult(outcome.result, `${location}.result`);
282
+ return;
283
+ case "failed":
284
+ case "cancelled":
285
+ exactKeys(outcome, ["outcome", "error"], [], location);
286
+ validateErrorInfo(outcome.error, `${location}.error`);
287
+ return;
288
+ case "timedOut":
289
+ exactKeys(outcome, ["outcome", "error", "timeoutMs"], [], location);
290
+ validateErrorInfo(outcome.error, `${location}.error`);
291
+ safeUnsignedIntegerAt(outcome.timeoutMs, `${location}.timeoutMs`);
292
+ return;
293
+ default:
294
+ invalidEvent(`${location}.outcome`, "has an unknown Action outcome");
295
+ }
296
+ }
297
+ function validateRecordedActionCall(value, location) {
298
+ const call = recordAt(value, location);
299
+ // `arguments` is intentionally required here even though the historical
300
+ // generated type marks its serde default as optional. Without an own value,
301
+ // redaction cannot be distinguished from an invalid or lossy delivery.
302
+ exactKeys(call, ["id", "name", "arguments"], ["argumentsRedacted"], location);
303
+ uuidAt(call.id, `${location}.id`);
304
+ stringAt(call.name, `${location}.name`);
305
+ if (Object.hasOwn(call, "argumentsRedacted")) {
306
+ const redacted = booleanAt(call.argumentsRedacted, `${location}.argumentsRedacted`);
307
+ if (!redacted) {
308
+ invalidEvent(location, "argumentsRedacted must be omitted when false");
309
+ }
310
+ if (redacted && call.arguments !== null) {
311
+ invalidEvent(location, "redacted Action arguments must be null");
312
+ }
313
+ }
314
+ }
315
+ function validatePayload(value, location) {
316
+ const payload = recordAt(value, location);
317
+ const type = stringAt(payload.type, `${location}.type`);
318
+ switch (type) {
319
+ case "sessionStarted":
320
+ exactKeys(payload, ["type"], [], location);
321
+ return;
322
+ case "sessionEnded":
323
+ exactKeys(payload, ["type", "outcome", "reason"], [], location);
324
+ enumAt(payload.outcome, ["completed", "failed", "cancelled", "shutdown"], `${location}.outcome`);
325
+ if (payload.reason !== null) {
326
+ stringAt(payload.reason, `${location}.reason`);
327
+ }
328
+ return;
329
+ case "observationCaptured":
330
+ exactKeys(payload, ["type", "observation"], [], location);
331
+ validateObservation(payload.observation, `${location}.observation`);
332
+ return;
333
+ case "actionStarted":
334
+ exactKeys(payload, ["type", "call"], [], location);
335
+ validateRecordedActionCall(payload.call, `${location}.call`);
336
+ return;
337
+ case "actionCompleted":
338
+ exactKeys(payload, ["type", "callId", "outcome"], [], location);
339
+ uuidAt(payload.callId, `${location}.callId`);
340
+ validateActionOutcome(payload.outcome, `${location}.outcome`);
341
+ return;
342
+ case "mediaStreamStarted": {
343
+ exactKeys(payload, ["type", "stream"], [], location);
344
+ const stream = recordAt(payload.stream, `${location}.stream`);
345
+ exactKeys(stream, ["id", "kind", "mediaType"], ["viewport"], `${location}.stream`);
346
+ uuidAt(stream.id, `${location}.stream.id`);
347
+ enumAt(stream.kind, ["screenshot", "video"], `${location}.stream.kind`);
348
+ stringAt(stream.mediaType, `${location}.stream.mediaType`);
349
+ if (stream.viewport !== undefined) {
350
+ validateViewport(stream.viewport, `${location}.stream.viewport`);
351
+ }
352
+ return;
353
+ }
354
+ case "mediaFrameCaptured": {
355
+ exactKeys(payload, ["type", "frame"], [], location);
356
+ const frame = recordAt(payload.frame, `${location}.frame`);
357
+ exactKeys(frame, ["streamId", "frameIndex", "evidence"], ["keyFrame", "durationMs"], `${location}.frame`);
358
+ uuidAt(frame.streamId, `${location}.frame.streamId`);
359
+ safeUnsignedIntegerAt(frame.frameIndex, `${location}.frame.frameIndex`, 1);
360
+ if (frame.keyFrame !== undefined) {
361
+ booleanAt(frame.keyFrame, `${location}.frame.keyFrame`);
362
+ }
363
+ if (frame.durationMs !== undefined) {
364
+ safeUnsignedIntegerAt(frame.durationMs, `${location}.frame.durationMs`);
365
+ }
366
+ validateAssetRef(frame.evidence, `${location}.frame.evidence`);
367
+ return;
368
+ }
369
+ case "mediaStreamEnded":
370
+ exactKeys(payload, ["type", "streamId", "frameCount"], [], location);
371
+ uuidAt(payload.streamId, `${location}.streamId`);
372
+ safeUnsignedIntegerAt(payload.frameCount, `${location}.frameCount`);
373
+ return;
374
+ case "verdictRecorded":
375
+ exactKeys(payload, ["type", "verdict"], [], location);
376
+ validateVerdict(payload.verdict, `${location}.verdict`);
377
+ return;
378
+ case "error":
379
+ exactKeys(payload, ["type", "error"], [], location);
380
+ validateErrorInfo(payload.error, `${location}.error`);
381
+ return;
382
+ default:
383
+ invalidEvent(`${location}.type`, "has an unknown TestEvent payload type");
384
+ }
385
+ }
386
+ function observationUsesProtocol15(value) {
387
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
388
+ return false;
389
+ }
390
+ return Object.hasOwn(value, "uiSnapshot") || Object.hasOwn(value, "uiSnapshotOmission");
391
+ }
392
+ function payloadUsesProtocol15(value) {
393
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
394
+ return false;
395
+ }
396
+ const payload = value;
397
+ if (payload.type === "observationCaptured") {
398
+ return observationUsesProtocol15(payload.observation);
399
+ }
400
+ if (payload.type !== "actionCompleted") {
401
+ return false;
402
+ }
403
+ const outcome = payload.outcome;
404
+ if (outcome === null || typeof outcome !== "object" || Array.isArray(outcome)) {
405
+ return false;
406
+ }
407
+ const outcomeRecord = outcome;
408
+ const result = outcomeRecord.result;
409
+ if (outcomeRecord.outcome !== "succeeded" ||
410
+ result === null ||
411
+ typeof result !== "object" ||
412
+ Array.isArray(result)) {
413
+ return false;
414
+ }
415
+ const resultRecord = result;
416
+ return Object.hasOwn(resultRecord, "execution") ||
417
+ observationUsesProtocol15(resultRecord.before) ||
418
+ observationUsesProtocol15(resultRecord.after);
419
+ }
420
+ function validatePayloadProtocol(payload, protocol, location) {
421
+ if (protocol.major !== 1 || protocol.minor < 0 || protocol.minor > 5) {
422
+ invalidEvent(location, "uses an unsupported event protocol version");
423
+ }
424
+ const payloadType = payload.type;
425
+ if (protocol.minor < 4 &&
426
+ (payloadType === "mediaStreamStarted" ||
427
+ payloadType === "mediaFrameCaptured" ||
428
+ payloadType === "mediaStreamEnded")) {
429
+ invalidEvent(location, "requires event protocol 1.4 or newer");
430
+ }
431
+ if (protocol.minor < 5 && payloadUsesProtocol15(payload)) {
432
+ invalidEvent(location, "requires event protocol 1.5 or newer");
433
+ }
434
+ }
435
+ function cloneJson(value, location, context, depth) {
436
+ context.nodes += 1;
437
+ if (context.nodes > MAX_JSON_NODES) {
438
+ return invalidEvent(location, `exceeds the ${String(MAX_JSON_NODES)} JSON node limit`);
439
+ }
440
+ if (depth > MAX_JSON_DEPTH) {
441
+ return invalidEvent(location, `exceeds the ${String(MAX_JSON_DEPTH)} JSON depth limit`);
442
+ }
443
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
444
+ return value;
445
+ }
446
+ if (typeof value === "number") {
447
+ if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) {
448
+ return invalidEvent(location, "contains an unsafe JSON number");
449
+ }
450
+ return value;
451
+ }
452
+ if (typeof value !== "object") {
453
+ return invalidEvent(location, "contains a non-JSON value");
454
+ }
455
+ if (context.seen.has(value)) {
456
+ return invalidEvent(location, "contains a repeated or cyclic object");
457
+ }
458
+ context.seen.add(value);
459
+ if (Array.isArray(value)) {
460
+ const array = arrayAt(value, location);
461
+ return array.map((entry, index) => cloneJson(entry, `${location}[${String(index)}]`, context, depth + 1));
462
+ }
463
+ const record = recordAt(value, location);
464
+ const clone = {};
465
+ for (const [key, entry] of Object.entries(record)) {
466
+ Object.defineProperty(clone, key, {
467
+ configurable: true,
468
+ enumerable: true,
469
+ value: cloneJson(entry, `${location}.${key}`, context, depth + 1),
470
+ writable: true,
471
+ });
472
+ }
473
+ return clone;
474
+ }
475
+ function deepFreeze(value) {
476
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
477
+ return;
478
+ }
479
+ for (const child of Array.isArray(value) ? value : Object.values(value)) {
480
+ deepFreeze(child);
481
+ }
482
+ Object.freeze(value);
483
+ }
484
+ /** Runtime-validates and takes an immutable snapshot of one untrusted event. */
485
+ export function validateTestEvent(value, location = "event", protocol = { major: 1, minor: 5 }) {
486
+ const event = recordAt(value, location);
487
+ exactKeys(event, ["eventId", "sessionId", "sequence", "atMs", "payload"], ["requestId", "deviceId"], location);
488
+ uuidAt(event.eventId, `${location}.eventId`);
489
+ uuidAt(event.sessionId, `${location}.sessionId`);
490
+ safeUnsignedIntegerAt(event.sequence, `${location}.sequence`, 1);
491
+ safeUnsignedIntegerAt(event.atMs, `${location}.atMs`);
492
+ if (Object.hasOwn(event, "requestId")) {
493
+ validateRpcId(event.requestId, `${location}.requestId`);
494
+ }
495
+ if (Object.hasOwn(event, "deviceId")) {
496
+ stringAt(event.deviceId, `${location}.deviceId`);
497
+ }
498
+ validatePayload(event.payload, `${location}.payload`);
499
+ validatePayloadProtocol(event.payload, protocol, `${location}.payload`);
500
+ const snapshot = cloneJson(event, location, { nodes: 0, seen: new WeakSet() }, 1);
501
+ deepFreeze(snapshot);
502
+ return snapshot;
503
+ }
504
+ function jsonEqual(left, right) {
505
+ if (Object.is(left, right)) {
506
+ return true;
507
+ }
508
+ if (typeof left !== typeof right || left === null || right === null) {
509
+ return false;
510
+ }
511
+ if (Array.isArray(left) || Array.isArray(right)) {
512
+ return (Array.isArray(left) &&
513
+ Array.isArray(right) &&
514
+ left.length === right.length &&
515
+ left.every((entry, index) => jsonEqual(entry, right[index])));
516
+ }
517
+ if (typeof left !== "object" || typeof right !== "object") {
518
+ return false;
519
+ }
520
+ const leftRecord = left;
521
+ const rightRecord = right;
522
+ const leftKeys = Object.keys(leftRecord);
523
+ const rightKeys = Object.keys(rightRecord);
524
+ return (leftKeys.length === rightKeys.length &&
525
+ leftKeys.every((key) => Object.hasOwn(rightRecord, key) && jsonEqual(leftRecord[key], rightRecord[key])));
526
+ }
527
+ function correlation(event, field) {
528
+ if (!Object.hasOwn(event, field)) {
529
+ return ABSENT;
530
+ }
531
+ const value = event[field];
532
+ if (value === null || typeof value === "string" || typeof value === "number") {
533
+ return value;
534
+ }
535
+ // Runtime validation above makes this unreachable.
536
+ return ABSENT;
537
+ }
538
+ function stateError(code, message, details) {
539
+ throw new RecorderError(code, message, { details });
540
+ }
541
+ function applyNewEvent(state, event) {
542
+ const payload = event.payload;
543
+ const first = state.eventCount === 0;
544
+ if (first && payload.type !== "sessionStarted") {
545
+ stateError("invalid_lifecycle", "sequence 1 must be sessionStarted", {
546
+ sequence: event.sequence,
547
+ });
548
+ }
549
+ if (!first && payload.type === "sessionStarted") {
550
+ stateError("invalid_lifecycle", "sessionStarted may only appear at sequence 1", {
551
+ sequence: event.sequence,
552
+ });
553
+ }
554
+ if (state.eventIds.has(event.eventId)) {
555
+ stateError("duplicate_event_id", "eventId was reused at a different sequence", {
556
+ eventId: event.eventId,
557
+ sequence: event.sequence,
558
+ });
559
+ }
560
+ switch (payload.type) {
561
+ case "actionStarted": {
562
+ if (state.seenCallIds.has(payload.call.id)) {
563
+ stateError("action_call_reused", "Action call id was reused", {
564
+ callId: payload.call.id,
565
+ sequence: event.sequence,
566
+ });
567
+ }
568
+ state.seenCallIds.add(payload.call.id);
569
+ state.openActions.set(payload.call.id, {
570
+ deviceId: correlation(event, "deviceId"),
571
+ requestId: correlation(event, "requestId"),
572
+ });
573
+ break;
574
+ }
575
+ case "actionCompleted": {
576
+ const started = state.openActions.get(payload.callId);
577
+ if (!started) {
578
+ stateError("action_not_started", "Action completion has no open ActionStarted", {
579
+ callId: payload.callId,
580
+ sequence: event.sequence,
581
+ });
582
+ }
583
+ if (!Object.is(started.requestId, correlation(event, "requestId")) ||
584
+ !Object.is(started.deviceId, correlation(event, "deviceId"))) {
585
+ stateError("action_correlation_mismatch", "Action event correlation changed", {
586
+ callId: payload.callId,
587
+ sequence: event.sequence,
588
+ });
589
+ }
590
+ if (payload.outcome.outcome === "succeeded" &&
591
+ payload.outcome.result.callId !== payload.callId) {
592
+ stateError("action_result_mismatch", "successful ActionResult has a different callId", {
593
+ callId: payload.callId,
594
+ resultCallId: payload.outcome.result.callId,
595
+ sequence: event.sequence,
596
+ });
597
+ }
598
+ state.openActions.delete(payload.callId);
599
+ break;
600
+ }
601
+ case "mediaStreamStarted": {
602
+ if (state.seenMediaStreamIds.has(payload.stream.id)) {
603
+ stateError("invalid_lifecycle", "media stream id was reused", {
604
+ sequence: event.sequence,
605
+ streamId: payload.stream.id,
606
+ });
607
+ }
608
+ state.seenMediaStreamIds.add(payload.stream.id);
609
+ state.openMediaStreams.set(payload.stream.id, {
610
+ mediaType: payload.stream.mediaType,
611
+ nextFrameIndex: 1,
612
+ });
613
+ break;
614
+ }
615
+ case "mediaFrameCaptured": {
616
+ const stream = state.openMediaStreams.get(payload.frame.streamId);
617
+ if (!stream
618
+ || payload.frame.frameIndex !== stream.nextFrameIndex
619
+ || payload.frame.evidence.mediaType !== stream.mediaType) {
620
+ stateError("invalid_lifecycle", "media frame does not match an active stream", {
621
+ sequence: event.sequence,
622
+ streamId: payload.frame.streamId,
623
+ });
624
+ }
625
+ state.openMediaStreams.set(payload.frame.streamId, {
626
+ mediaType: stream.mediaType,
627
+ nextFrameIndex: stream.nextFrameIndex + 1,
628
+ });
629
+ break;
630
+ }
631
+ case "mediaStreamEnded": {
632
+ const stream = state.openMediaStreams.get(payload.streamId);
633
+ if (!stream || payload.frameCount !== stream.nextFrameIndex - 1) {
634
+ stateError("invalid_lifecycle", "media stream frame count is inconsistent", {
635
+ sequence: event.sequence,
636
+ streamId: payload.streamId,
637
+ });
638
+ }
639
+ state.openMediaStreams.delete(payload.streamId);
640
+ break;
641
+ }
642
+ case "sessionEnded":
643
+ if (state.openActions.size !== 0) {
644
+ stateError("action_in_flight", "Session ended with Action calls still open", {
645
+ openActionCount: state.openActions.size,
646
+ sequence: event.sequence,
647
+ });
648
+ }
649
+ if (state.openMediaStreams.size !== 0) {
650
+ stateError("invalid_lifecycle", "Session ended with media streams still open", {
651
+ openMediaStreamCount: state.openMediaStreams.size,
652
+ sequence: event.sequence,
653
+ });
654
+ }
655
+ state.terminal = true;
656
+ break;
657
+ case "sessionStarted":
658
+ case "observationCaptured":
659
+ case "verdictRecorded":
660
+ case "error":
661
+ break;
662
+ }
663
+ state.appendedEvents.push(event);
664
+ state.eventCount += 1;
665
+ state.eventIds.add(event.eventId);
666
+ }
667
+ /** Sequence-authoritative, transport-neutral Session event accumulator. */
668
+ export class EventLog {
669
+ #sessionId;
670
+ #eventProtocolVersion;
671
+ #identity = Object.freeze({});
672
+ #eventIds = new Set();
673
+ #eventChunks = [];
674
+ #eventsSnapshot = Object.freeze([]);
675
+ #openActions = new Map();
676
+ #openMediaStreams = new Map();
677
+ #seenCallIds = new Set();
678
+ #seenMediaStreamIds = new Set();
679
+ #terminal = false;
680
+ #generation = 0;
681
+ constructor(sessionId, eventProtocolVersion = { major: 1, minor: 5 }) {
682
+ this.#sessionId = uuidAt(sessionId, "sessionId");
683
+ this.#eventProtocolVersion = { ...eventProtocolVersion };
684
+ }
685
+ /** Strictly reconstructs a canonical checkpoint log. */
686
+ static replay(sessionId, events, eventProtocolVersion = { major: 1, minor: 5 }) {
687
+ const log = new EventLog(sessionId, eventProtocolVersion);
688
+ const result = log.acceptBatch(events);
689
+ if (result.duplicates !== 0) {
690
+ stateError("sequence_conflict", "checkpoint replay contains duplicate sequences", {
691
+ duplicates: result.duplicates,
692
+ });
693
+ }
694
+ return log;
695
+ }
696
+ get sessionId() {
697
+ return this.#sessionId;
698
+ }
699
+ get events() {
700
+ this.#eventsSnapshot ??= Object.freeze(this.#eventChunks.flat());
701
+ return this.#eventsSnapshot;
702
+ }
703
+ get lastSequence() {
704
+ return this.#eventChunks.at(-1)?.at(-1)?.sequence ?? null;
705
+ }
706
+ get nextSequence() {
707
+ return (this.lastSequence ?? 0) + 1;
708
+ }
709
+ get terminal() {
710
+ return this.#terminal;
711
+ }
712
+ get openActionCount() {
713
+ return this.#openActions.size;
714
+ }
715
+ /**
716
+ * Creates an isolated speculative branch. Immutable event chunks are shared;
717
+ * identity indexes are copied because subsequent writes to either branch must
718
+ * remain independent. The Recorder hot path uses `prepareBatch` instead.
719
+ */
720
+ fork() {
721
+ const candidate = new EventLog(this.#sessionId, this.#eventProtocolVersion);
722
+ candidate.#eventIds = new Set(this.#eventIds);
723
+ candidate.#eventChunks = [...this.#eventChunks];
724
+ candidate.#eventsSnapshot = this.#eventsSnapshot;
725
+ candidate.#openActions = new Map(this.#openActions);
726
+ candidate.#openMediaStreams = new Map(this.#openMediaStreams);
727
+ candidate.#seenCallIds = new Set(this.#seenCallIds);
728
+ candidate.#seenMediaStreamIds = new Set(this.#seenMediaStreamIds);
729
+ candidate.#terminal = this.#terminal;
730
+ candidate.#generation = this.#generation;
731
+ return candidate;
732
+ }
733
+ accept(value) {
734
+ const result = this.acceptBatch([value]);
735
+ return result.accepted === 1 ? "accepted" : "duplicate";
736
+ }
737
+ /** Validate a batch without copying or mutating the confirmed event prefix. */
738
+ prepareBatch(values) {
739
+ const incoming = values.map((value, index) => validateTestEvent(value, `events[${String(index)}]`, this.#eventProtocolVersion));
740
+ for (const event of incoming) {
741
+ if (event.sessionId !== this.#sessionId) {
742
+ stateError("session_mismatch", "event belongs to a different Session", {
743
+ actualSessionId: event.sessionId,
744
+ expectedSessionId: this.#sessionId,
745
+ sequence: event.sequence,
746
+ });
747
+ }
748
+ }
749
+ const baseEventCount = this.lastSequence ?? 0;
750
+ const state = {
751
+ owner: this.#identity,
752
+ eventCount: baseEventCount,
753
+ appendedEvents: [],
754
+ eventIds: new OverlaySet(this.#eventIds),
755
+ openActions: new Map(this.#openActions),
756
+ openMediaStreams: new Map(this.#openMediaStreams),
757
+ seenCallIds: new OverlaySet(this.#seenCallIds),
758
+ seenMediaStreamIds: new OverlaySet(this.#seenMediaStreamIds),
759
+ terminal: this.#terminal,
760
+ };
761
+ let duplicates = 0;
762
+ for (const [index, event] of incoming.entries()) {
763
+ const expected = state.eventCount + 1;
764
+ if (event.sequence < expected) {
765
+ const appendedIndex = event.sequence - baseEventCount - 1;
766
+ const existing = appendedIndex >= 0
767
+ ? state.appendedEvents[appendedIndex]
768
+ : this.#eventAt(event.sequence);
769
+ if (!existing || !jsonEqual(existing, event)) {
770
+ stateError("sequence_conflict", "a delivered sequence differs from its recorded event", {
771
+ sequence: event.sequence,
772
+ });
773
+ }
774
+ duplicates += 1;
775
+ continue;
776
+ }
777
+ if (state.terminal) {
778
+ stateError("terminal_append", "a new event cannot follow sessionEnded", {
779
+ sequence: event.sequence,
780
+ });
781
+ }
782
+ if (event.sequence > expected) {
783
+ const expectedAppearsLater = incoming
784
+ .slice(index + 1)
785
+ .some((candidate) => candidate.sequence === expected);
786
+ stateError(expectedAppearsLater ? "out_of_order" : "sequence_gap", expectedAppearsLater
787
+ ? "event delivery is out of sequence"
788
+ : "event delivery contains a sequence gap", { actualSequence: event.sequence, expectedSequence: expected });
789
+ }
790
+ applyNewEvent(state, event);
791
+ }
792
+ const result = Object.freeze({
793
+ accepted: state.appendedEvents.length,
794
+ duplicates,
795
+ lastSequence: state.eventCount === 0 ? null : state.eventCount,
796
+ terminal: state.terminal,
797
+ });
798
+ return Object.freeze({
799
+ baseGeneration: this.#generation,
800
+ acceptedEvents: Object.freeze([...state.appendedEvents]),
801
+ result,
802
+ [PREPARED_STATE]: state,
803
+ });
804
+ }
805
+ /** Commit a previously validated batch without replaying its durable prefix. */
806
+ commitPreparedBatch(prepared) {
807
+ const state = prepared[PREPARED_STATE];
808
+ if (state.owner !== this.#identity || prepared.baseGeneration !== this.#generation) {
809
+ stateError("sequence_conflict", "event log changed after the batch was prepared", {
810
+ actualGeneration: this.#generation,
811
+ expectedGeneration: prepared.baseGeneration,
812
+ });
813
+ }
814
+ if (prepared.acceptedEvents.length === 0) {
815
+ return prepared.result;
816
+ }
817
+ this.#eventChunks.push(Object.freeze([...prepared.acceptedEvents]));
818
+ this.#eventsSnapshot = undefined;
819
+ for (const eventId of state.eventIds.added) {
820
+ this.#eventIds.add(eventId);
821
+ }
822
+ for (const callId of state.seenCallIds.added) {
823
+ this.#seenCallIds.add(callId);
824
+ }
825
+ for (const streamId of state.seenMediaStreamIds.added) {
826
+ this.#seenMediaStreamIds.add(streamId);
827
+ }
828
+ this.#openActions = state.openActions;
829
+ this.#openMediaStreams = state.openMediaStreams;
830
+ this.#terminal = state.terminal;
831
+ this.#generation += 1;
832
+ return prepared.result;
833
+ }
834
+ #eventAt(sequence) {
835
+ let offset = sequence - 1;
836
+ for (const chunk of this.#eventChunks) {
837
+ if (offset < chunk.length) {
838
+ return chunk[offset];
839
+ }
840
+ offset -= chunk.length;
841
+ }
842
+ return undefined;
843
+ }
844
+ /**
845
+ * Accepts one delivery batch atomically. Any malformed event or state-machine
846
+ * failure leaves the prior log unchanged.
847
+ */
848
+ acceptBatch(values) {
849
+ return this.commitPreparedBatch(this.prepareBatch(values));
850
+ }
851
+ snapshot() {
852
+ return Object.freeze({
853
+ sessionId: this.sessionId,
854
+ events: this.events,
855
+ lastSequence: this.lastSequence,
856
+ terminal: this.terminal,
857
+ openActionCount: this.openActionCount,
858
+ });
859
+ }
860
+ }