@sublang/playbook 10.0.0 → 12.0.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +67 -19
  3. package/docs/configuration.md +103 -40
  4. package/docs/embedding.md +88 -0
  5. package/package.json +30 -4
  6. package/reference/sdlc/code.md +35 -15
  7. package/reference/sdlc/code.playbook/bin/interactive-session.js +58 -6
  8. package/reference/sdlc/code.playbook/bin/launch-config.js +499 -241
  9. package/reference/sdlc/code.playbook/bin/playbook.js +236 -187
  10. package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
  11. package/reference/sdlc/code.playbook/bin/run.js +355 -203
  12. package/reference/sdlc/code.playbook/bin/session-store.js +1512 -136
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +22 -19
  14. package/reference/sdlc/code.playbook/code.fsm.js +116 -52
  15. package/reference/sdlc/code.playbook/code.fsm.ts +149 -64
  16. package/reference/sdlc/code.playbook/code.gears.md +40 -20
  17. package/reference/sdlc/code.playbook/code.playbook.js +23 -2
  18. package/reference/sdlc/code.playbook/code.playbook.ts +23 -2
  19. package/reference/sdlc/code.playbook/playbook-captain.d.ts +4 -1
  20. package/reference/sdlc/code.playbook/playbook-captain.js +21 -3
  21. package/reference/sdlc/code.playbook/playbook-captain.ts +42 -6
  22. package/reference/sdlc/code.playbook/playbook.config.template.yaml +31 -11
  23. package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
  24. package/reference/sdlc/code.playbook/session-store.js +113 -0
  25. package/reference/sdlc/decide.md +24 -15
  26. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +13 -6
  27. package/reference/sdlc/decide.playbook/decide.fsm.js +54 -27
  28. package/reference/sdlc/decide.playbook/decide.fsm.ts +68 -29
  29. package/reference/sdlc/decide.playbook/decide.gears.md +25 -19
  30. package/reference/sdlc/decide.playbook/decide.playbook.js +11 -3
  31. package/reference/sdlc/decide.playbook/decide.playbook.ts +11 -3
  32. package/reference/sdlc/decide.playbook/decide.registry.js +1 -1
  33. package/reference/sdlc/decide.playbook/decide.registry.ts +1 -1
  34. package/reference/sdlc/dev.md +52 -0
  35. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +261 -0
  36. package/reference/sdlc/dev.playbook/dev.fsm.js +723 -0
  37. package/reference/sdlc/dev.playbook/dev.fsm.ts +988 -0
  38. package/reference/sdlc/dev.playbook/dev.gears.md +91 -0
  39. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +21 -0
  40. package/reference/sdlc/dev.playbook/dev.playbook.js +143 -0
  41. package/reference/sdlc/dev.playbook/dev.playbook.ts +246 -0
  42. package/reference/sdlc/dev.playbook/dev.registry.d.ts +40 -0
  43. package/reference/sdlc/dev.playbook/dev.registry.js +64 -0
  44. package/reference/sdlc/dev.playbook/dev.registry.ts +120 -0
  45. package/reference/sdlc/review.md +36 -18
  46. package/reference/sdlc/review.playbook/review.fsm.d.ts +15 -2
  47. package/reference/sdlc/review.playbook/review.fsm.js +77 -27
  48. package/reference/sdlc/review.playbook/review.fsm.ts +96 -30
  49. package/reference/sdlc/review.playbook/review.gears.md +52 -26
  50. package/reference/sdlc/review.playbook/review.playbook.js +17 -7
  51. package/reference/sdlc/review.playbook/review.playbook.ts +17 -7
  52. package/reference/sdlc/review.playbook/review.registry.js +1 -1
  53. package/reference/sdlc/review.playbook/review.registry.ts +1 -1
  54. package/slc/link.md +12 -5
  55. package/slc/text2gears.md +3 -0
  56. package/src/xstate-playbook-runtime.js +5 -2
  57. package/src/xstate-playbook-runtime.ts +5 -2
  58. package/src/xstate-runtime.js +13 -1
  59. package/src/xstate-runtime.ts +13 -1
@@ -6,9 +6,10 @@
6
6
  // PBCLI-53: a fresh lease may move its newest same-directory predecessor's
7
7
  // complete retained-generation map under guarded source-first publication.
8
8
 
9
- import { randomUUID } from 'node:crypto';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
10
  import { constants } from 'node:fs';
11
11
  import {
12
+ access,
12
13
  chmod,
13
14
  link,
14
15
  lstat,
@@ -39,6 +40,7 @@ import {
39
40
 
40
41
  export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 6;
41
42
  export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
43
+ export const RECORDS_STREAM_VERSION = 1;
42
44
  const LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 3;
43
45
  // The interrupted retention change emitted this required-member shape before
44
46
  // canonical writes returned to additive schema 3. Both pre-effect shapes and
@@ -120,7 +122,11 @@ const LEASE_OWNER_KEYS = [
120
122
  'hostname',
121
123
  'acquiredAt',
122
124
  ];
125
+ const PLAYBOOK_SESSION_NOT_FOUND = 'PLAYBOOK_SESSION_NOT_FOUND';
126
+ const PLAYBOOK_SESSION_LEASE_ACTIVE = 'PLAYBOOK_SESSION_LEASE_ACTIVE';
127
+ const ACTIVE_LEASE_ERROR = Symbol('active Playbook session lease');
123
128
  const DEFAULT_FS_OPERATIONS = Object.freeze({
129
+ access,
124
130
  chmod,
125
131
  link,
126
132
  lstat,
@@ -131,6 +137,15 @@ const DEFAULT_FS_OPERATIONS = Object.freeze({
131
137
  rmdir,
132
138
  unlink,
133
139
  });
140
+ const EMPTY_REPLAY_PREFIX_DIGEST = createHash('sha256').digest('hex');
141
+ const REPLAY_READ_CHUNK_SIZE = 64 * 1024;
142
+
143
+ class ReplaySnapshotChangedError extends Error {
144
+ constructor(message) {
145
+ super(message);
146
+ this.name = 'ReplaySnapshotChangedError';
147
+ }
148
+ }
134
149
 
135
150
  class CaptainSessionRecordSchemaError extends Error {
136
151
  constructor(schemaVersion, message, cause) {
@@ -150,17 +165,42 @@ class CaptainSessionRecordNonresumableError extends
150
165
  }
151
166
  }
152
167
 
168
+ class CaptainSessionNotFoundError extends Error {
169
+ constructor(sessionId, path) {
170
+ super(
171
+ `Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} does not exist`,
172
+ );
173
+ this.name = 'CaptainSessionNotFoundError';
174
+ this.code = PLAYBOOK_SESSION_NOT_FOUND;
175
+ }
176
+ }
177
+
153
178
  class CaptainSessionLeaseActiveError extends Error {
154
179
  constructor(sessionId, pid) {
155
180
  super(
156
181
  `cannot acquire Captain session ${JSON.stringify(sessionId)} lease: Captain session lease is active in process ${pid}`,
157
182
  );
158
183
  this.name = 'CaptainSessionLeaseActiveError';
184
+ this.code = PLAYBOOK_SESSION_LEASE_ACTIVE;
185
+ this[ACTIVE_LEASE_ERROR] = true;
159
186
  this.sessionId = sessionId;
160
187
  this.pid = pid;
161
188
  }
162
189
  }
163
190
 
191
+ function foreignCaptainSessionLeaseActiveError(sessionId, hostname) {
192
+ const error = new Error(
193
+ `Captain session ${JSON.stringify(sessionId)} lease is owned by foreign host ${JSON.stringify(hostname)}`,
194
+ );
195
+ error.code = PLAYBOOK_SESSION_LEASE_ACTIVE;
196
+ error[ACTIVE_LEASE_ERROR] = true;
197
+ return error;
198
+ }
199
+
200
+ function isActiveLeaseError(value) {
201
+ return value?.[ACTIVE_LEASE_ERROR] === true;
202
+ }
203
+
164
204
  export function defaultCaptainSessionsDir(
165
205
  env = process.env,
166
206
  home = env.HOME ?? homedir(),
@@ -169,6 +209,398 @@ export function defaultCaptainSessionsDir(
169
209
  return join(stateHome, 'playbook', 'sessions');
170
210
  }
171
211
 
212
+ // PBCLI-78: front-end bootstrap checks the same private filesystem boundary
213
+ // as later store operations without creating an otherwise-unused directory.
214
+ // A missing leaf is usable because the first lease publication creates it.
215
+ export async function assertCaptainSessionsDirectoryUsable(
216
+ sessionsDir,
217
+ options = {},
218
+ ) {
219
+ if (typeof sessionsDir !== 'string' || !isAbsolute(sessionsDir)) {
220
+ throw new Error('Captain session store path must be absolute');
221
+ }
222
+ const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
223
+ try {
224
+ await assertPrivateDirectory(sessionsDir, fs);
225
+ await fs.access(
226
+ sessionsDir,
227
+ constants.R_OK | constants.W_OK | constants.X_OK,
228
+ );
229
+ return;
230
+ } catch (cause) {
231
+ if (cause?.code !== 'ENOENT') throw cause;
232
+ }
233
+
234
+ // Reject a symlink or non-directory anywhere in an otherwise missing path;
235
+ // mode 0700 becomes mandatory once the sessions directory itself exists.
236
+ let cursor = sessionsDir;
237
+ for (;;) {
238
+ try {
239
+ await assertDirectoryNotLink(cursor, fs);
240
+ await fs.access(cursor, constants.W_OK | constants.X_OK);
241
+ return;
242
+ } catch (cause) {
243
+ if (cause?.code !== 'ENOENT') throw cause;
244
+ const parent = dirname(cursor);
245
+ if (parent === cursor) throw cause;
246
+ cursor = parent;
247
+ }
248
+ }
249
+ }
250
+
251
+ // PBCLI-73: caller-shape rejection precedes the PBCLI-75 sanitizer so Task 3
252
+ // can keep non-latching argument errors distinct from latching data failures.
253
+ export function assertReplayAppendArguments(record, role) {
254
+ if (
255
+ typeof record !== 'object' ||
256
+ record === null ||
257
+ Array.isArray(record)
258
+ ) {
259
+ throw new TypeError('replay append record must be an object');
260
+ }
261
+ if (
262
+ role !== undefined &&
263
+ (typeof role !== 'string' || role.length === 0)
264
+ ) {
265
+ throw new TypeError('replay append role must be a nonempty string');
266
+ }
267
+ return record;
268
+ }
269
+
270
+ export function sanitizeReplayRecord(value) {
271
+ const sanitized = sanitizeReplayValue(value, 'replay record', new Set());
272
+ return requireRecord(sanitized, 'replay record');
273
+ }
274
+
275
+ // PBCLI-75/76/83: one lease-owned writer repairs the retained prefix, queues
276
+ // appends, and keeps replay durability fail-soft beside canonical settlement.
277
+ async function createLeaseReplayWriter({
278
+ sessionsDir,
279
+ path,
280
+ fs,
281
+ assertOwner,
282
+ readStream,
283
+ }) {
284
+ let lastReadableSeq = null;
285
+ let lastDurableSeq = null;
286
+ let incomplete = true;
287
+ let identity;
288
+ let completeOffset = 0;
289
+ let writerHandle;
290
+ let operationTail = Promise.resolve();
291
+ let appendAdmissionClosed = false;
292
+
293
+ const status = () =>
294
+ Object.freeze({ lastReadableSeq, lastDurableSeq, incomplete });
295
+
296
+ const enqueue = (operation) => {
297
+ const result = operationTail.then(operation);
298
+ operationTail = result.then(
299
+ () => undefined,
300
+ () => undefined,
301
+ );
302
+ return result;
303
+ };
304
+
305
+ const latch = () => {
306
+ if (lastReadableSeq !== null) incomplete = true;
307
+ };
308
+
309
+ const updateReadableFromDisk = async () => {
310
+ if (lastReadableSeq === null) return;
311
+ try {
312
+ const snapshot = await readReplayWriterSnapshot({
313
+ sessionsDir,
314
+ path,
315
+ identity,
316
+ fs,
317
+ });
318
+ if (snapshot.absent) return;
319
+ const parsed = parseReplayLines(
320
+ snapshot.bytes,
321
+ 0,
322
+ EMPTY_REPLAY_PREFIX_DIGEST,
323
+ );
324
+ lastReadableSeq = parsed.sequence;
325
+ completeOffset = parsed.completeBytes;
326
+ identity = snapshot.identity;
327
+ } catch {
328
+ // The already-established boundary remains the only trustworthy one.
329
+ }
330
+ };
331
+
332
+ const initialize = async () => {
333
+ let snapshot;
334
+ await assertOwner();
335
+ try {
336
+ snapshot = await readReplayWriterSnapshot({
337
+ sessionsDir,
338
+ path,
339
+ fs,
340
+ });
341
+ if (snapshot.absent) {
342
+ lastReadableSeq = 0;
343
+ lastDurableSeq = 0;
344
+ incomplete = false;
345
+ completeOffset = 0;
346
+ return;
347
+ }
348
+
349
+ const parsed = parseReplayLines(
350
+ snapshot.bytes,
351
+ 0,
352
+ EMPTY_REPLAY_PREFIX_DIGEST,
353
+ );
354
+ const prefixSequence = parsed.sequence;
355
+ lastReadableSeq = prefixSequence;
356
+ lastDurableSeq = prefixSequence;
357
+ incomplete = false;
358
+ identity = snapshot.identity;
359
+ completeOffset = parsed.completeBytes;
360
+ const tail = snapshot.bytes.subarray(parsed.completeBytes);
361
+ if (tail.length === 0) return;
362
+
363
+ let retainTail = false;
364
+ try {
365
+ parseReplayEnvelope(tail, prefixSequence + 1);
366
+ retainTail = true;
367
+ } catch {
368
+ // Every other torn tail is discarded at the complete-prefix boundary.
369
+ }
370
+
371
+ if (retainTail) {
372
+ try {
373
+ writerHandle = await mutateReplayWriterFile({
374
+ sessionsDir,
375
+ path,
376
+ identity,
377
+ expectedSize: snapshot.bytes.length,
378
+ expectedFinalSize: snapshot.bytes.length + 1,
379
+ fs,
380
+ retainHandle: true,
381
+ operation: async (handle) => {
382
+ await writeReplayRange(
383
+ handle,
384
+ Buffer.from('\n'),
385
+ snapshot.bytes.length,
386
+ );
387
+ completeOffset = snapshot.bytes.length + 1;
388
+ lastReadableSeq = prefixSequence + 1;
389
+ await handle.sync();
390
+ },
391
+ });
392
+ lastDurableSeq = prefixSequence + 1;
393
+ } catch {
394
+ await updateReadableFromDisk();
395
+ latch();
396
+ }
397
+ return;
398
+ }
399
+
400
+ try {
401
+ writerHandle = await mutateReplayWriterFile({
402
+ sessionsDir,
403
+ path,
404
+ identity,
405
+ expectedSize: snapshot.bytes.length,
406
+ expectedFinalSize: parsed.completeBytes,
407
+ fs,
408
+ retainHandle: true,
409
+ operation: async (handle) => {
410
+ await handle.truncate(parsed.completeBytes);
411
+ completeOffset = parsed.completeBytes;
412
+ await handle.sync();
413
+ },
414
+ });
415
+ } catch {
416
+ await updateReadableFromDisk();
417
+ latch();
418
+ }
419
+ } catch {
420
+ lastReadableSeq = null;
421
+ lastDurableSeq = null;
422
+ incomplete = true;
423
+ identity = undefined;
424
+ completeOffset = 0;
425
+ }
426
+ };
427
+
428
+ const appendAtQueueHead = async (record, role) => {
429
+ if (lastReadableSeq === null || incomplete) return undefined;
430
+
431
+ let sanitized;
432
+ let sanitizationFailed = false;
433
+ let sanitizationFailure;
434
+ try {
435
+ sanitized = sanitizeReplayRecord(record);
436
+ } catch (cause) {
437
+ sanitizationFailed = true;
438
+ sanitizationFailure = cause;
439
+ }
440
+ // Sanitization may invoke caller-controlled Proxy traps. This one owner
441
+ // check is the append's cooperative-lease linearization point.
442
+ await assertOwner();
443
+ if (sanitizationFailed) {
444
+ latch();
445
+ throw sanitizationFailure;
446
+ }
447
+
448
+ if (lastReadableSeq >= Number.MAX_SAFE_INTEGER) {
449
+ latch();
450
+ throw new Error('replay stream sequence exhausted its safe integer range');
451
+ }
452
+ const sequence = lastReadableSeq + 1;
453
+ const line = Buffer.from(
454
+ `${JSON.stringify({
455
+ v: RECORDS_STREAM_VERSION,
456
+ seq: sequence,
457
+ ...(role === undefined ? {} : { role }),
458
+ record: sanitized,
459
+ })}\n`,
460
+ );
461
+ const publishing = identity === undefined;
462
+ try {
463
+ if (publishing) {
464
+ const published = await publishReplayWriterFile({
465
+ sessionsDir,
466
+ path,
467
+ bytes: line,
468
+ fs,
469
+ });
470
+ identity = published.identity;
471
+ writerHandle = published.handle;
472
+ completeOffset = line.length;
473
+ lastReadableSeq = sequence;
474
+ await syncDirectory(sessionsDir, fs);
475
+ } else if (writerHandle === undefined) {
476
+ writerHandle = await mutateReplayWriterFile({
477
+ sessionsDir,
478
+ path,
479
+ identity,
480
+ expectedSize: completeOffset,
481
+ expectedFinalSize: completeOffset + line.length,
482
+ fs,
483
+ retainHandle: true,
484
+ operation: (handle) =>
485
+ writeReplayRange(handle, line, completeOffset),
486
+ });
487
+ completeOffset += line.length;
488
+ lastReadableSeq = sequence;
489
+ } else {
490
+ await appendReplayWriterFile({
491
+ handle: writerHandle,
492
+ identity,
493
+ expectedSize: completeOffset,
494
+ bytes: line,
495
+ });
496
+ completeOffset += line.length;
497
+ lastReadableSeq = sequence;
498
+ }
499
+ return undefined;
500
+ } catch (cause) {
501
+ await updateReadableFromDisk();
502
+ latch();
503
+ throw cause;
504
+ }
505
+ };
506
+
507
+ const append = (record, role) => {
508
+ if (appendAdmissionClosed) {
509
+ return Promise.reject(
510
+ new Error('replay append admission is closed for release'),
511
+ );
512
+ }
513
+ if (lastReadableSeq === null || incomplete) return Promise.resolve();
514
+ try {
515
+ assertReplayAppendArguments(record, role);
516
+ } catch (cause) {
517
+ return Promise.reject(cause);
518
+ }
519
+ return enqueue(() => appendAtQueueHead(record, role));
520
+ };
521
+
522
+ const read = (options) => {
523
+ if (appendAdmissionClosed) {
524
+ return Promise.reject(new Error('replay writer is closing'));
525
+ }
526
+ return enqueue(async () => {
527
+ await assertOwner();
528
+ if (lastReadableSeq === null) {
529
+ throw new Error('replay stream is unavailable for this lease');
530
+ }
531
+ const result = await readStream(options);
532
+ lastReadableSeq = result.lastReadableSeq;
533
+ return freezeReplayLeaseReadResult(
534
+ result.entries,
535
+ lastReadableSeq,
536
+ lastDurableSeq,
537
+ incomplete,
538
+ );
539
+ });
540
+ };
541
+
542
+ const checkpointAtQueueHead = async () => {
543
+ if (
544
+ lastReadableSeq === null ||
545
+ incomplete ||
546
+ lastReadableSeq === lastDurableSeq
547
+ ) {
548
+ return;
549
+ }
550
+ try {
551
+ await assertOwner();
552
+ if (writerHandle === undefined) {
553
+ throw new Error('replay stream checkpoint has no writer handle');
554
+ }
555
+ await checkpointReplayWriterFile({
556
+ sessionsDir,
557
+ path,
558
+ handle: writerHandle,
559
+ identity,
560
+ expectedSize: completeOffset,
561
+ fs,
562
+ });
563
+ await assertOwner();
564
+ lastDurableSeq = lastReadableSeq;
565
+ } catch {
566
+ await updateReadableFromDisk();
567
+ latch();
568
+ }
569
+ };
570
+
571
+ const checkpoint = () => enqueue(checkpointAtQueueHead);
572
+
573
+ const closeAppendAdmission = () => {
574
+ appendAdmissionClosed = true;
575
+ };
576
+
577
+ const prepareRelease = () =>
578
+ enqueue(async () => {
579
+ try {
580
+ await checkpointAtQueueHead();
581
+ } finally {
582
+ const handle = writerHandle;
583
+ writerHandle = undefined;
584
+ try {
585
+ await handle?.close();
586
+ } catch {
587
+ // A completed checkpoint owns durability; descriptor cleanup cannot
588
+ // broaden the latch trigger set or block canonical lease retirement.
589
+ }
590
+ }
591
+ });
592
+
593
+ await initialize();
594
+ return Object.freeze({
595
+ append,
596
+ read,
597
+ status,
598
+ checkpoint,
599
+ closeAppendAdmission,
600
+ prepareRelease,
601
+ });
602
+ }
603
+
172
604
  export function createCaptainSessionStore(options = {}) {
173
605
  const env = options.env ?? process.env;
174
606
  const home = options.homeDir ?? env.HOME ?? homedir();
@@ -182,6 +614,8 @@ export function createCaptainSessionStore(options = {}) {
182
614
  const probeProcess =
183
615
  options.probeProcess ?? ((pid) => process.kill(pid, 0));
184
616
  const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
617
+ const replayReadCursors = new Map();
618
+ const replayReadQueues = new Map();
185
619
 
186
620
  if (!isAbsolute(sessionsDir)) {
187
621
  throw new Error('Captain session store path must be absolute');
@@ -203,6 +637,10 @@ export function createCaptainSessionStore(options = {}) {
203
637
  assertSessionId(sessionId);
204
638
  return join(sessionsDir, `${sessionId}.json`);
205
639
  };
640
+ const recordsPathFor = (sessionId) => {
641
+ assertSessionId(sessionId);
642
+ return join(sessionsDir, `${sessionId}.records.jsonl`);
643
+ };
206
644
  const leasePathFor = (sessionId) => {
207
645
  assertSessionId(sessionId);
208
646
  return join(sessionsDir, `.${sessionId}.lock`);
@@ -222,9 +660,7 @@ export function createCaptainSessionStore(options = {}) {
222
660
  } catch (cause) {
223
661
  if (cause?.code === 'ENOENT' && missing === 'undefined') return undefined;
224
662
  if (cause?.code === 'ENOENT') {
225
- throw new Error(
226
- `Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} does not exist`,
227
- );
663
+ throw new CaptainSessionNotFoundError(sessionId, path);
228
664
  }
229
665
  throw new Error(
230
666
  `cannot read Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)}: ${errorMessage(cause)}`,
@@ -286,6 +722,40 @@ export function createCaptainSessionStore(options = {}) {
286
722
 
287
723
  const read = (sessionId) => readRecord(sessionId);
288
724
 
725
+ const readStream = async (sessionId, options) => {
726
+ assertSessionId(sessionId);
727
+ const afterSeq = validateReplayReadOptions(options);
728
+ const previous = replayReadQueues.get(sessionId) ?? Promise.resolve();
729
+ const operation = previous.then(() =>
730
+ readReplayStream({
731
+ sessionsDir,
732
+ path: recordsPathFor(sessionId),
733
+ afterSeq,
734
+ cursor: replayReadCursors.get(sessionId),
735
+ fs,
736
+ }),
737
+ );
738
+ const drained = operation.then(
739
+ (result) => {
740
+ if (result.cursor === undefined) {
741
+ replayReadCursors.delete(sessionId);
742
+ } else {
743
+ replayReadCursors.set(sessionId, result.cursor);
744
+ }
745
+ return undefined;
746
+ },
747
+ () => undefined,
748
+ );
749
+ replayReadQueues.set(sessionId, drained);
750
+ void drained.then(() => {
751
+ if (replayReadQueues.get(sessionId) === drained) {
752
+ replayReadQueues.delete(sessionId);
753
+ }
754
+ });
755
+ const result = await operation;
756
+ return result.value;
757
+ };
758
+
289
759
  const listRecords = async ({
290
760
  onLegacyRecord,
291
761
  onLegacyOrderingBoundary,
@@ -381,6 +851,39 @@ export function createCaptainSessionStore(options = {}) {
381
851
  );
382
852
  };
383
853
 
854
+ const readSummary = async (sessionId) =>
855
+ projectPlaybookSessionSummary(await readRecord(sessionId));
856
+
857
+ const listSummaries = async () => {
858
+ const skipped = [];
859
+ const records = await listRecords({
860
+ onLegacyRecord: ({ sessionId, schemaVersion }) => {
861
+ skipped.push(
862
+ Object.freeze({
863
+ sessionId,
864
+ reason:
865
+ `Captain session schema ${schemaVersion} is below current ` +
866
+ `schema ${CAPTAIN_SESSION_RECORD_SCHEMA_VERSION}`,
867
+ }),
868
+ );
869
+ },
870
+ onInvalidRecord: ({ sessionId, reason }) => {
871
+ skipped.push(Object.freeze({ sessionId, reason }));
872
+ },
873
+ skipInvalidRecords: true,
874
+ });
875
+ return Object.freeze({
876
+ sessions: Object.freeze(
877
+ sortCaptainSessionRecords(records).map(projectPlaybookSessionSummary),
878
+ ),
879
+ skipped: Object.freeze(
880
+ skipped.sort((left, right) =>
881
+ left.sessionId.localeCompare(right.sessionId),
882
+ ),
883
+ ),
884
+ });
885
+ };
886
+
384
887
  const scanAdoptionPredecessor = async (
385
888
  target,
386
889
  { onLegacyRecord, onInvalidRecord } = {},
@@ -580,6 +1083,24 @@ export function createCaptainSessionStore(options = {}) {
580
1083
  const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
581
1084
  readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
582
1085
 
1086
+ const activeLeaseErrorFor = async (sessionId, owner) => {
1087
+ if (owner.hostname !== localHostname) {
1088
+ return foreignCaptainSessionLeaseActiveError(
1089
+ sessionId,
1090
+ owner.hostname,
1091
+ );
1092
+ }
1093
+ try {
1094
+ await probeProcess(owner.pid);
1095
+ return new CaptainSessionLeaseActiveError(sessionId, owner.pid);
1096
+ } catch (cause) {
1097
+ if (cause?.code === 'ESRCH') return undefined;
1098
+ throw new Error(
1099
+ `Captain session lease owner process cannot be ruled dead: ${errorMessage(cause)}`,
1100
+ );
1101
+ }
1102
+ };
1103
+
583
1104
  const readRetiredLease = async (sessionId, path, expectedToken) => {
584
1105
  const owner = await readLeaseDirectory(
585
1106
  sessionId,
@@ -710,146 +1231,963 @@ export function createCaptainSessionStore(options = {}) {
710
1231
  fs,
711
1232
  'Captain session lease retired path is already occupied',
712
1233
  );
713
- try {
714
- await fs.rename(canonicalPath, retiredPath);
715
- } catch (cause) {
716
- throw new Error(
717
- `Captain session lease changed before retirement: ${errorMessage(cause)}`,
718
- );
1234
+ try {
1235
+ await fs.rename(canonicalPath, retiredPath);
1236
+ } catch (cause) {
1237
+ throw new Error(
1238
+ `Captain session lease changed before retirement: ${errorMessage(cause)}`,
1239
+ );
1240
+ }
1241
+ const retiredOwner = await readLeaseDirectory(
1242
+ sessionId,
1243
+ retiredPath,
1244
+ [LEASE_OWNER_FILE],
1245
+ );
1246
+ if (retiredOwner.ownerToken !== owner.ownerToken) {
1247
+ throw new Error('Captain session retired lease owner token changed');
1248
+ }
1249
+ await syncDirectory(sessionsDir, fs);
1250
+ return retiredPath;
1251
+ };
1252
+
1253
+ const publishLeaseStage = async (sessionId, stage, onRenamed) => {
1254
+ const canonicalPath = leasePathFor(sessionId);
1255
+ await validateRetiredLeases(sessionId);
1256
+ try {
1257
+ await assertPathMissing(
1258
+ canonicalPath,
1259
+ fs,
1260
+ 'Captain session lease became active before publication',
1261
+ );
1262
+ // Program-created canonical lease directories are nonempty. Therefore a
1263
+ // racing rename cannot replace one; it fails closed instead. Static empty
1264
+ // or malformed destinations are rejected by the preflight above.
1265
+ await fs.rename(stage.stagePath, canonicalPath);
1266
+ onRenamed();
1267
+ } catch (cause) {
1268
+ try {
1269
+ const winner = await readLeaseOwner(sessionId);
1270
+ const active = await activeLeaseErrorFor(sessionId, winner);
1271
+ if (active !== undefined) throw active;
1272
+ } catch (winnerCause) {
1273
+ if (isActiveLeaseError(winnerCause)) {
1274
+ throw winnerCause;
1275
+ }
1276
+ }
1277
+ throw new Error(
1278
+ `Captain session lease publication lost its race: ${errorMessage(cause)}`,
1279
+ );
1280
+ }
1281
+ await syncDirectory(sessionsDir, fs);
1282
+ const publishedOwner = await readLeaseOwner(sessionId);
1283
+ if (publishedOwner.ownerToken !== stage.owner.ownerToken) {
1284
+ throw new Error('Captain session lease publication owner token changed');
1285
+ }
1286
+ return publishedOwner;
1287
+ };
1288
+
1289
+ const acquire = async (sessionId) => {
1290
+ assertSessionId(sessionId);
1291
+ let stage;
1292
+ let stagePublished = false;
1293
+ try {
1294
+ stage = await makeLeaseStage(sessionId);
1295
+ const canonicalPath = leasePathFor(sessionId);
1296
+ let existing;
1297
+ try {
1298
+ existing = await readLeaseOwner(sessionId);
1299
+ } catch (cause) {
1300
+ if (cause?.code !== 'ENOENT') throw cause;
1301
+ }
1302
+
1303
+ if (existing !== undefined) {
1304
+ if (existing.ownerToken === stage.owner.ownerToken) {
1305
+ throw new Error('Captain session lease owner token was reused');
1306
+ }
1307
+ const active = await activeLeaseErrorFor(sessionId, existing);
1308
+ if (active !== undefined) throw active;
1309
+ await retireObservedLease(sessionId, existing);
1310
+ } else {
1311
+ // Preserve the explicit local solely for easier audit of the no-owner
1312
+ // publication boundary.
1313
+ void canonicalPath;
1314
+ }
1315
+
1316
+ await publishLeaseStage(sessionId, stage, () => {
1317
+ stagePublished = true;
1318
+ });
1319
+ return await createLease({
1320
+ sessionId,
1321
+ owner: stage.owner,
1322
+ sessionsDir,
1323
+ replayPath: recordsPathFor(sessionId),
1324
+ replayFs: fs,
1325
+ readReplayStream: (options) => readStream(sessionId, options),
1326
+ readRecord,
1327
+ writeRecord,
1328
+ syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
1329
+ deleteRecord,
1330
+ scanAdoptionPredecessor,
1331
+ acquireSession: acquire,
1332
+ readLeaseOwner,
1333
+ retireObservedLease,
1334
+ validateRetiredLeases,
1335
+ now,
1336
+ });
1337
+ } catch (cause) {
1338
+ let cleanupError;
1339
+ if (stage !== undefined && stagePublished) {
1340
+ try {
1341
+ const current = await readLeaseOwner(sessionId);
1342
+ if (current.ownerToken === stage.owner.ownerToken) {
1343
+ await retireObservedLease(sessionId, current);
1344
+ }
1345
+ } catch (error) {
1346
+ cleanupError = error;
1347
+ }
1348
+ } else if (stage !== undefined) {
1349
+ await cleanOwnStage(stage.stagePath);
1350
+ }
1351
+ if (cleanupError !== undefined) {
1352
+ throw new AggregateError(
1353
+ [cause, cleanupError],
1354
+ `cannot acquire Captain session ${JSON.stringify(sessionId)} lease without leaving ownership uncertain`,
1355
+ );
1356
+ }
1357
+ if (isActiveLeaseError(cause)) throw cause;
1358
+ throw new Error(
1359
+ `cannot acquire Captain session ${JSON.stringify(sessionId)} lease: ${errorMessage(cause)}`,
1360
+ );
1361
+ }
1362
+ };
1363
+
1364
+ return Object.freeze({
1365
+ sessionsDir,
1366
+ listSummaries,
1367
+ readSummary,
1368
+ read,
1369
+ readStream,
1370
+ latest,
1371
+ acquire,
1372
+ });
1373
+ }
1374
+
1375
+ async function readReplayStream({
1376
+ sessionsDir,
1377
+ path,
1378
+ afterSeq,
1379
+ cursor,
1380
+ fs,
1381
+ }) {
1382
+ let forceFullRead = false;
1383
+ let snapshot;
1384
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1385
+ try {
1386
+ snapshot = await readReplaySnapshot({
1387
+ sessionsDir,
1388
+ path,
1389
+ afterSeq,
1390
+ cursor,
1391
+ forceFullRead,
1392
+ fs,
1393
+ });
1394
+ break;
1395
+ } catch (cause) {
1396
+ if (!(cause instanceof ReplaySnapshotChangedError) || attempt === 1) {
1397
+ throw cause;
1398
+ }
1399
+ if (cursor !== undefined) cursor.reusable = false;
1400
+ forceFullRead = true;
1401
+ }
1402
+ }
1403
+
1404
+ if (snapshot === undefined) {
1405
+ throw new Error('replay stream changed during both read snapshots');
1406
+ }
1407
+ if (snapshot.absent) {
1408
+ if (cursor !== undefined) cursor.reusable = false;
1409
+ if (cursor !== undefined && cursor.sequence > 0) {
1410
+ throw new Error('replay stream rolled back below its observed prefix');
1411
+ }
1412
+ if (afterSeq !== 0) {
1413
+ throw new Error(
1414
+ `replay stream afterSeq ${afterSeq} exceeds last readable sequence 0`,
1415
+ );
1416
+ }
1417
+ return {
1418
+ cursor: undefined,
1419
+ value: freezeReplayReadResult([], 0),
1420
+ };
1421
+ }
1422
+
1423
+ const initialSequence = snapshot.incremental ? cursor.sequence : 0;
1424
+ const initialDigest = snapshot.incremental
1425
+ ? cursor.digest
1426
+ : EMPTY_REPLAY_PREFIX_DIGEST;
1427
+ const parsed = parseReplayLines(
1428
+ snapshot.bytes,
1429
+ initialSequence,
1430
+ initialDigest,
1431
+ snapshot.incremental ? undefined : cursor?.sequence,
1432
+ );
1433
+ const lastReadableSeq = parsed.sequence;
1434
+
1435
+ if (!snapshot.incremental && cursor !== undefined && cursor.sequence > 0) {
1436
+ if (
1437
+ lastReadableSeq < cursor.sequence ||
1438
+ parsed.observedDigest !== cursor.digest
1439
+ ) {
1440
+ throw new Error('replay stream rolled back or changed its observed prefix');
1441
+ }
1442
+ }
1443
+ if (afterSeq > lastReadableSeq) {
1444
+ throw new Error(
1445
+ `replay stream afterSeq ${afterSeq} exceeds last readable sequence ${lastReadableSeq}`,
1446
+ );
1447
+ }
1448
+
1449
+ const completeOffset = snapshot.startOffset + parsed.completeBytes;
1450
+ const nextCursor = {
1451
+ identity: snapshot.identity,
1452
+ offset: completeOffset,
1453
+ sequence: lastReadableSeq,
1454
+ digest: parsed.digest,
1455
+ reusable: true,
1456
+ };
1457
+ const entries = parsed.entries.filter((entry) => entry.seq > afterSeq);
1458
+ return {
1459
+ cursor: nextCursor,
1460
+ value: freezeReplayReadResult(entries, lastReadableSeq),
1461
+ };
1462
+ }
1463
+
1464
+ async function readReplaySnapshot({
1465
+ sessionsDir,
1466
+ path,
1467
+ afterSeq,
1468
+ cursor,
1469
+ forceFullRead,
1470
+ fs,
1471
+ }) {
1472
+ try {
1473
+ await assertPrivateDirectory(sessionsDir, fs);
1474
+ } catch (cause) {
1475
+ if (cause?.code === 'ENOENT') return { absent: true };
1476
+ throw cause;
1477
+ }
1478
+
1479
+ let pathStat;
1480
+ try {
1481
+ pathStat = await assertPrivateRegularPath(path, 0o600, fs, 'replay stream');
1482
+ } catch (cause) {
1483
+ if (cause?.code === 'ENOENT') return { absent: true };
1484
+ throw cause;
1485
+ }
1486
+
1487
+ let handle;
1488
+ try {
1489
+ handle = await fs.open(
1490
+ path,
1491
+ constants.O_RDONLY |
1492
+ (constants.O_NOFOLLOW ?? 0) |
1493
+ (constants.O_NONBLOCK ?? 0),
1494
+ );
1495
+ } catch (cause) {
1496
+ if (cause?.code === 'ENOENT') {
1497
+ throw new ReplaySnapshotChangedError(
1498
+ 'replay stream disappeared while opening its snapshot',
1499
+ );
1500
+ }
1501
+ throw cause;
1502
+ }
1503
+
1504
+ try {
1505
+ const openedStat = await handle.stat();
1506
+ assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
1507
+ if (!sameFileIdentity(pathStat, openedStat)) {
1508
+ throw new ReplaySnapshotChangedError(
1509
+ 'replay stream was replaced while opening its snapshot',
1510
+ );
1511
+ }
1512
+ const snapshotLength = requireReplayFileSize(openedStat.size);
1513
+ const identity = replayFileIdentity(openedStat);
1514
+ const cursorBoundaryChanged =
1515
+ cursor !== undefined &&
1516
+ (!sameReplayIdentity(cursor.identity, identity) ||
1517
+ snapshotLength < cursor.offset);
1518
+ if (cursorBoundaryChanged) cursor.reusable = false;
1519
+ const incremental =
1520
+ !forceFullRead &&
1521
+ cursor !== undefined &&
1522
+ cursor.reusable &&
1523
+ sameReplayIdentity(cursor.identity, identity) &&
1524
+ snapshotLength >= cursor.offset &&
1525
+ afterSeq >= cursor.sequence;
1526
+ const startOffset = incremental ? cursor.offset : 0;
1527
+ const bytes = await readReplayRange(handle, startOffset, snapshotLength);
1528
+
1529
+ const finalHandleStat = await handle.stat();
1530
+ assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream');
1531
+ if (
1532
+ !sameFileIdentity(openedStat, finalHandleStat) ||
1533
+ requireReplayFileSize(finalHandleStat.size) < snapshotLength
1534
+ ) {
1535
+ throw new ReplaySnapshotChangedError(
1536
+ 'replay stream was truncated within its pinned snapshot',
1537
+ );
1538
+ }
1539
+
1540
+ let finalPathStat;
1541
+ try {
1542
+ finalPathStat = await assertPrivateRegularPath(
1543
+ path,
1544
+ 0o600,
1545
+ fs,
1546
+ 'replay stream',
1547
+ );
1548
+ } catch (cause) {
1549
+ if (cause?.code === 'ENOENT') {
1550
+ throw new ReplaySnapshotChangedError(
1551
+ 'replay stream disappeared within its pinned snapshot',
1552
+ );
1553
+ }
1554
+ throw cause;
1555
+ }
1556
+ if (!sameFileIdentity(openedStat, finalPathStat)) {
1557
+ throw new ReplaySnapshotChangedError(
1558
+ 'replay stream was replaced within its pinned snapshot',
1559
+ );
1560
+ }
1561
+ await assertPrivateDirectory(sessionsDir, fs);
1562
+
1563
+ return {
1564
+ absent: false,
1565
+ bytes,
1566
+ identity,
1567
+ incremental,
1568
+ startOffset,
1569
+ };
1570
+ } finally {
1571
+ await handle.close();
1572
+ }
1573
+ }
1574
+
1575
+ async function readReplayRange(handle, start, end) {
1576
+ const buffer = Buffer.allocUnsafe(end - start);
1577
+ let offset = 0;
1578
+ while (offset < buffer.length) {
1579
+ const length = Math.min(REPLAY_READ_CHUNK_SIZE, buffer.length - offset);
1580
+ const result = await handle.read(buffer, offset, length, start + offset);
1581
+ if (
1582
+ result === null ||
1583
+ typeof result !== 'object' ||
1584
+ !Number.isSafeInteger(result.bytesRead) ||
1585
+ result.bytesRead <= 0 ||
1586
+ result.bytesRead > length
1587
+ ) {
1588
+ throw new ReplaySnapshotChangedError(
1589
+ 'replay stream ended before its pinned snapshot boundary',
1590
+ );
1591
+ }
1592
+ offset += result.bytesRead;
1593
+ }
1594
+ return buffer;
1595
+ }
1596
+
1597
+ async function writeReplayRange(handle, bytes, start) {
1598
+ let offset = 0;
1599
+ while (offset < bytes.length) {
1600
+ const result = await handle.write(
1601
+ bytes,
1602
+ offset,
1603
+ bytes.length - offset,
1604
+ start + offset,
1605
+ );
1606
+ if (
1607
+ result === null ||
1608
+ typeof result !== 'object' ||
1609
+ !Number.isSafeInteger(result.bytesWritten) ||
1610
+ result.bytesWritten <= 0 ||
1611
+ result.bytesWritten > bytes.length - offset
1612
+ ) {
1613
+ throw new Error('replay stream append did not write its complete bytes');
1614
+ }
1615
+ offset += result.bytesWritten;
1616
+ }
1617
+ }
1618
+
1619
+ async function readReplayWriterSnapshot({
1620
+ sessionsDir,
1621
+ path,
1622
+ identity,
1623
+ fs,
1624
+ }) {
1625
+ await assertPrivateDirectory(sessionsDir, fs);
1626
+ let pathStat;
1627
+ try {
1628
+ pathStat = await assertPrivateRegularPath(path, 0o600, fs, 'replay stream');
1629
+ } catch (cause) {
1630
+ if (cause?.code === 'ENOENT' && identity === undefined) {
1631
+ return { absent: true };
1632
+ }
1633
+ throw cause;
1634
+ }
1635
+ let handle;
1636
+ try {
1637
+ handle = await fs.open(
1638
+ path,
1639
+ constants.O_RDONLY |
1640
+ (constants.O_NOFOLLOW ?? 0) |
1641
+ (constants.O_NONBLOCK ?? 0),
1642
+ );
1643
+ const openedStat = await handle.stat();
1644
+ assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
1645
+ if (
1646
+ !sameFileIdentity(pathStat, openedStat) ||
1647
+ (identity !== undefined && !sameReplayIdentity(identity, openedStat))
1648
+ ) {
1649
+ throw new Error('replay stream identity changed for its writer');
1650
+ }
1651
+ const size = requireReplayFileSize(openedStat.size);
1652
+ const bytes = await readReplayRange(handle, 0, size);
1653
+ const finalHandleStat = await handle.stat();
1654
+ assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream');
1655
+ if (
1656
+ !sameFileIdentity(openedStat, finalHandleStat) ||
1657
+ requireReplayFileSize(finalHandleStat.size) !== size
1658
+ ) {
1659
+ throw new Error('replay stream changed during writer validation');
1660
+ }
1661
+ const finalPathStat = await assertPrivateRegularPath(
1662
+ path,
1663
+ 0o600,
1664
+ fs,
1665
+ 'replay stream',
1666
+ );
1667
+ if (!sameFileIdentity(openedStat, finalPathStat)) {
1668
+ throw new Error('replay stream path changed during writer validation');
1669
+ }
1670
+ await assertPrivateDirectory(sessionsDir, fs);
1671
+ return {
1672
+ absent: false,
1673
+ bytes,
1674
+ identity: replayFileIdentity(openedStat),
1675
+ };
1676
+ } finally {
1677
+ await handle?.close();
1678
+ }
1679
+ }
1680
+
1681
+ async function mutateReplayWriterFile({
1682
+ sessionsDir,
1683
+ path,
1684
+ identity,
1685
+ expectedSize,
1686
+ expectedFinalSize,
1687
+ fs,
1688
+ retainHandle = false,
1689
+ operation,
1690
+ }) {
1691
+ await assertPrivateDirectory(sessionsDir, fs);
1692
+ const pathStat = await assertPrivateRegularPath(
1693
+ path,
1694
+ 0o600,
1695
+ fs,
1696
+ 'replay stream',
1697
+ );
1698
+ if (!sameReplayIdentity(identity, pathStat)) {
1699
+ throw new Error('replay stream identity changed for its writer');
1700
+ }
1701
+ let handle;
1702
+ try {
1703
+ handle = await fs.open(
1704
+ path,
1705
+ constants.O_RDWR |
1706
+ (constants.O_NOFOLLOW ?? 0) |
1707
+ (constants.O_NONBLOCK ?? 0),
1708
+ );
1709
+ const openedStat = await handle.stat();
1710
+ assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
1711
+ if (
1712
+ !sameFileIdentity(pathStat, openedStat) ||
1713
+ !sameReplayIdentity(identity, openedStat) ||
1714
+ requireReplayFileSize(openedStat.size) !== expectedSize
1715
+ ) {
1716
+ throw new Error('replay stream changed before writer mutation');
1717
+ }
1718
+ await operation(handle);
1719
+ const finalStat = await handle.stat();
1720
+ assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
1721
+ if (
1722
+ !sameFileIdentity(openedStat, finalStat) ||
1723
+ (expectedFinalSize !== undefined &&
1724
+ requireReplayFileSize(finalStat.size) !== expectedFinalSize)
1725
+ ) {
1726
+ throw new Error('replay stream changed during writer mutation');
1727
+ }
1728
+ const finalPathStat = await assertPrivateRegularPath(
1729
+ path,
1730
+ 0o600,
1731
+ fs,
1732
+ 'replay stream',
1733
+ );
1734
+ if (!sameFileIdentity(openedStat, finalPathStat)) {
1735
+ throw new Error('replay stream path changed during writer mutation');
1736
+ }
1737
+ await assertPrivateDirectory(sessionsDir, fs);
1738
+ if (retainHandle) {
1739
+ const retained = handle;
1740
+ handle = undefined;
1741
+ return retained;
1742
+ }
1743
+ } finally {
1744
+ await handle?.close();
1745
+ }
1746
+ }
1747
+
1748
+ async function appendReplayWriterFile({
1749
+ handle,
1750
+ identity,
1751
+ expectedSize,
1752
+ bytes,
1753
+ }) {
1754
+ const openedStat = await handle.stat();
1755
+ assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
1756
+ if (
1757
+ !sameReplayIdentity(identity, openedStat) ||
1758
+ requireReplayFileSize(openedStat.size) !== expectedSize
1759
+ ) {
1760
+ throw new Error('replay stream changed before writer append');
1761
+ }
1762
+ await writeReplayRange(handle, bytes, expectedSize);
1763
+ }
1764
+
1765
+ async function checkpointReplayWriterFile({
1766
+ sessionsDir,
1767
+ path,
1768
+ handle,
1769
+ identity,
1770
+ expectedSize,
1771
+ fs,
1772
+ }) {
1773
+ await assertPrivateDirectory(sessionsDir, fs);
1774
+ const pathStat = await assertPrivateRegularPath(
1775
+ path,
1776
+ 0o600,
1777
+ fs,
1778
+ 'replay stream',
1779
+ );
1780
+ const openedStat = await handle.stat();
1781
+ assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
1782
+ if (
1783
+ !sameFileIdentity(pathStat, openedStat) ||
1784
+ !sameReplayIdentity(identity, openedStat) ||
1785
+ requireReplayFileSize(openedStat.size) !== expectedSize
1786
+ ) {
1787
+ throw new Error('replay stream changed before writer checkpoint');
1788
+ }
1789
+ await handle.sync();
1790
+ const finalStat = await handle.stat();
1791
+ assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
1792
+ if (
1793
+ !sameFileIdentity(openedStat, finalStat) ||
1794
+ requireReplayFileSize(finalStat.size) !== expectedSize
1795
+ ) {
1796
+ throw new Error('replay stream changed during writer checkpoint');
1797
+ }
1798
+ const finalPathStat = await assertPrivateRegularPath(
1799
+ path,
1800
+ 0o600,
1801
+ fs,
1802
+ 'replay stream',
1803
+ );
1804
+ if (!sameFileIdentity(openedStat, finalPathStat)) {
1805
+ throw new Error('replay stream path changed during writer checkpoint');
1806
+ }
1807
+ await assertPrivateDirectory(sessionsDir, fs);
1808
+ }
1809
+
1810
+ async function publishReplayWriterFile({ sessionsDir, path, bytes, fs }) {
1811
+ await assertPrivateDirectory(sessionsDir, fs);
1812
+ let handle;
1813
+ try {
1814
+ handle = await fs.open(
1815
+ path,
1816
+ constants.O_RDWR |
1817
+ constants.O_CREAT |
1818
+ constants.O_EXCL |
1819
+ (constants.O_NOFOLLOW ?? 0) |
1820
+ (constants.O_NONBLOCK ?? 0),
1821
+ 0o600,
1822
+ );
1823
+ await handle.chmod(0o600);
1824
+ const openedStat = await handle.stat();
1825
+ assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
1826
+ if (requireReplayFileSize(openedStat.size) !== 0) {
1827
+ throw new Error('new replay stream is not empty');
719
1828
  }
720
- const retiredOwner = await readLeaseDirectory(
721
- sessionId,
722
- retiredPath,
723
- [LEASE_OWNER_FILE],
724
- );
725
- if (retiredOwner.ownerToken !== owner.ownerToken) {
726
- throw new Error('Captain session retired lease owner token changed');
1829
+ await writeReplayRange(handle, bytes, 0);
1830
+ const finalStat = await handle.stat();
1831
+ assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
1832
+ if (
1833
+ !sameFileIdentity(openedStat, finalStat) ||
1834
+ requireReplayFileSize(finalStat.size) !== bytes.length
1835
+ ) {
1836
+ throw new Error('new replay stream did not retain its complete append');
727
1837
  }
728
- await syncDirectory(sessionsDir, fs);
729
- return retiredPath;
730
- };
731
-
732
- const publishLeaseStage = async (sessionId, stage, onRenamed) => {
733
- const canonicalPath = leasePathFor(sessionId);
734
- await validateRetiredLeases(sessionId);
735
- await assertPathMissing(
736
- canonicalPath,
1838
+ const pathStat = await assertPrivateRegularPath(
1839
+ path,
1840
+ 0o600,
737
1841
  fs,
738
- 'Captain session lease became active before publication',
1842
+ 'replay stream',
739
1843
  );
740
- try {
741
- // Program-created canonical lease directories are nonempty. Therefore a
742
- // racing rename cannot replace one; it fails closed instead. Static empty
743
- // or malformed destinations are rejected by the preflight above.
744
- await fs.rename(stage.stagePath, canonicalPath);
745
- onRenamed();
746
- } catch (cause) {
747
- throw new Error(
748
- `Captain session lease publication lost its race: ${errorMessage(cause)}`,
749
- );
1844
+ if (!sameFileIdentity(openedStat, pathStat)) {
1845
+ throw new Error('new replay stream path changed during publication');
750
1846
  }
751
- await syncDirectory(sessionsDir, fs);
752
- const publishedOwner = await readLeaseOwner(sessionId);
753
- if (publishedOwner.ownerToken !== stage.owner.ownerToken) {
754
- throw new Error('Captain session lease publication owner token changed');
755
- }
756
- return publishedOwner;
1847
+ await assertPrivateDirectory(sessionsDir, fs);
1848
+ const retained = handle;
1849
+ handle = undefined;
1850
+ return {
1851
+ handle: retained,
1852
+ identity: replayFileIdentity(openedStat),
1853
+ };
1854
+ } finally {
1855
+ await handle?.close();
1856
+ }
1857
+ }
1858
+
1859
+ function parseReplayLines(bytes, initialSequence, initialDigest, observedSeq) {
1860
+ const entries = [];
1861
+ let sequence = initialSequence;
1862
+ let digest = initialDigest;
1863
+ let observedDigest = observedSeq === 0 ? initialDigest : undefined;
1864
+ let lineStart = 0;
1865
+
1866
+ for (let index = 0; index < bytes.length; index += 1) {
1867
+ if (bytes[index] !== 0x0a) continue;
1868
+ const line = bytes.subarray(lineStart, index);
1869
+ const entry = parseReplayEnvelope(line, sequence + 1);
1870
+ sequence = entry.seq;
1871
+ digest = digestReplayEntry(digest, entry);
1872
+ if (sequence === observedSeq) observedDigest = digest;
1873
+ entries.push(entry);
1874
+ lineStart = index + 1;
1875
+ }
1876
+
1877
+ return {
1878
+ completeBytes: lineStart,
1879
+ digest,
1880
+ entries,
1881
+ observedDigest,
1882
+ sequence,
757
1883
  };
1884
+ }
758
1885
 
759
- const acquire = async (sessionId) => {
760
- assertSessionId(sessionId);
761
- let stage;
762
- let stagePublished = false;
763
- try {
764
- stage = await makeLeaseStage(sessionId);
765
- const canonicalPath = leasePathFor(sessionId);
766
- let existing;
767
- try {
768
- existing = await readLeaseOwner(sessionId);
769
- } catch (cause) {
770
- if (cause?.code !== 'ENOENT') throw cause;
771
- }
1886
+ function parseReplayEnvelope(bytes, expectedSequence) {
1887
+ let text;
1888
+ try {
1889
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1890
+ } catch (cause) {
1891
+ throw new Error(
1892
+ `replay stream sequence ${expectedSequence} is not valid UTF-8: ${errorMessage(cause)}`,
1893
+ );
1894
+ }
772
1895
 
773
- if (existing !== undefined) {
774
- if (existing.ownerToken === stage.owner.ownerToken) {
775
- throw new Error('Captain session lease owner token was reused');
776
- }
777
- if (existing.hostname !== localHostname) {
778
- throw new Error(
779
- `Captain session lease is owned by foreign host ${JSON.stringify(existing.hostname)}`,
780
- );
781
- }
782
- try {
783
- await probeProcess(existing.pid);
784
- throw new CaptainSessionLeaseActiveError(
785
- sessionId,
786
- existing.pid,
787
- );
788
- } catch (cause) {
789
- if (cause?.code !== 'ESRCH') {
790
- if (cause instanceof CaptainSessionLeaseActiveError) throw cause;
791
- throw new Error(
792
- `Captain session lease owner process cannot be ruled dead: ${errorMessage(cause)}`,
793
- );
794
- }
795
- }
796
- await retireObservedLease(sessionId, existing);
797
- } else {
798
- // Preserve the explicit local solely for easier audit of the no-owner
799
- // publication boundary.
800
- void canonicalPath;
801
- }
1896
+ let parsed;
1897
+ try {
1898
+ parsed = JSON.parse(text);
1899
+ } catch (cause) {
1900
+ throw new Error(
1901
+ `replay stream sequence ${expectedSequence} is not valid JSON: ${errorMessage(cause)}`,
1902
+ );
1903
+ }
1904
+ const envelope = requireRecord(
1905
+ snapshotJsonValue(parsed, `replay stream sequence ${expectedSequence}`),
1906
+ `replay stream sequence ${expectedSequence}`,
1907
+ );
1908
+ exactOptionalKeys(
1909
+ envelope,
1910
+ ['v', 'seq', 'record'],
1911
+ ['role'],
1912
+ `replay stream sequence ${expectedSequence}`,
1913
+ );
1914
+ if (envelope.v !== RECORDS_STREAM_VERSION) {
1915
+ throw new Error(
1916
+ `replay stream sequence ${expectedSequence} version must be ${RECORDS_STREAM_VERSION}`,
1917
+ );
1918
+ }
1919
+ if (!Number.isSafeInteger(envelope.seq) || envelope.seq <= 0) {
1920
+ throw new Error(
1921
+ `replay stream sequence ${expectedSequence} seq must be a positive safe integer`,
1922
+ );
1923
+ }
1924
+ if (envelope.seq !== expectedSequence) {
1925
+ throw new Error(
1926
+ `replay stream expected sequence ${expectedSequence}, received ${envelope.seq}`,
1927
+ );
1928
+ }
1929
+ if (envelope.role !== undefined && typeof envelope.role !== 'string') {
1930
+ throw new Error(
1931
+ `replay stream sequence ${expectedSequence} role must be a string`,
1932
+ );
1933
+ }
1934
+ requireRecord(
1935
+ envelope.record,
1936
+ `replay stream sequence ${expectedSequence} record`,
1937
+ );
1938
+ return envelope;
1939
+ }
802
1940
 
803
- await publishLeaseStage(sessionId, stage, () => {
804
- stagePublished = true;
805
- });
806
- return createLease({
807
- sessionId,
808
- owner: stage.owner,
809
- readRecord,
810
- writeRecord,
811
- syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
812
- deleteRecord,
813
- scanAdoptionPredecessor,
814
- acquireSession: acquire,
815
- readLeaseOwner,
816
- retireObservedLease,
817
- validateRetiredLeases,
818
- now,
819
- });
820
- } catch (cause) {
821
- let cleanupError;
822
- if (stage !== undefined && stagePublished) {
823
- try {
824
- const current = await readLeaseOwner(sessionId);
825
- if (current.ownerToken === stage.owner.ownerToken) {
826
- await retireObservedLease(sessionId, current);
827
- }
828
- } catch (error) {
829
- cleanupError = error;
830
- }
831
- } else if (stage !== undefined) {
832
- await cleanOwnStage(stage.stagePath);
833
- }
834
- if (cleanupError !== undefined) {
835
- throw new AggregateError(
836
- [cause, cleanupError],
837
- `cannot acquire Captain session ${JSON.stringify(sessionId)} lease without leaving ownership uncertain`,
838
- );
1941
+ function validateReplayReadOptions(options) {
1942
+ if (
1943
+ options === undefined ||
1944
+ isExactUndefinedReplayReadOption(options)
1945
+ ) {
1946
+ return 0;
1947
+ }
1948
+ const value = requireRecord(
1949
+ snapshotJsonValue(options, 'replay stream read options'),
1950
+ 'replay stream read options',
1951
+ );
1952
+ exactOptionalKeys(value, [], ['afterSeq'], 'replay stream read options');
1953
+ const afterSeq = Object.hasOwn(value, 'afterSeq') ? value.afterSeq : 0;
1954
+ if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) {
1955
+ throw new Error(
1956
+ 'replay stream read options afterSeq must be a nonnegative safe integer',
1957
+ );
1958
+ }
1959
+ return afterSeq;
1960
+ }
1961
+
1962
+ function isExactUndefinedReplayReadOption(options) {
1963
+ if (
1964
+ options === null ||
1965
+ typeof options !== 'object' ||
1966
+ Array.isArray(options)
1967
+ ) {
1968
+ return false;
1969
+ }
1970
+ const prototype = Object.getPrototypeOf(options);
1971
+ if (prototype !== Object.prototype && prototype !== null) return false;
1972
+ const descriptors = Object.getOwnPropertyDescriptors(options);
1973
+ const keys = Reflect.ownKeys(descriptors);
1974
+ if (keys.length !== 1 || keys[0] !== 'afterSeq') return false;
1975
+ const descriptor = descriptors.afterSeq;
1976
+ return (
1977
+ descriptor.enumerable &&
1978
+ Object.hasOwn(descriptor, 'value') &&
1979
+ descriptor.value === undefined
1980
+ );
1981
+ }
1982
+
1983
+ function freezeReplayReadResult(entries, lastReadableSeq) {
1984
+ return Object.freeze({
1985
+ entries: Object.freeze(entries),
1986
+ lastReadableSeq,
1987
+ });
1988
+ }
1989
+
1990
+ function projectPlaybookSessionSummary(record) {
1991
+ return Object.freeze({
1992
+ schemaVersion: record.schemaVersion,
1993
+ sessionId: record.sessionId,
1994
+ state: record.state,
1995
+ cwd: record.cwd,
1996
+ updatedAt: record.updatedAt,
1997
+ });
1998
+ }
1999
+
2000
+ function freezeReplayLeaseReadResult(
2001
+ entries,
2002
+ lastReadableSeq,
2003
+ lastDurableSeq,
2004
+ incomplete,
2005
+ ) {
2006
+ return Object.freeze({
2007
+ entries: Object.freeze(entries),
2008
+ lastReadableSeq,
2009
+ lastDurableSeq,
2010
+ incomplete,
2011
+ });
2012
+ }
2013
+
2014
+ function replayFileIdentity(stat) {
2015
+ return Object.freeze({ dev: stat.dev, ino: stat.ino });
2016
+ }
2017
+
2018
+ function sameFileIdentity(left, right) {
2019
+ return left.dev === right.dev && left.ino === right.ino;
2020
+ }
2021
+
2022
+ function sameReplayIdentity(left, right) {
2023
+ return left.dev === right.dev && left.ino === right.ino;
2024
+ }
2025
+
2026
+ function requireReplayFileSize(value) {
2027
+ if (!Number.isSafeInteger(value) || value < 0) {
2028
+ throw new Error('replay stream size must be a nonnegative safe integer');
2029
+ }
2030
+ return value;
2031
+ }
2032
+
2033
+ function assertPrivateRegularStat(stat, mode, label) {
2034
+ if (!stat.isFile()) {
2035
+ throw new Error(`${label} path is not a regular file`);
2036
+ }
2037
+ if ((stat.mode & 0o7777) !== mode) {
2038
+ throw new Error(`${label} permissions must be ${octal(mode)}`);
2039
+ }
2040
+ }
2041
+
2042
+ function digestReplayEntry(previous, entry) {
2043
+ return createHash('sha256')
2044
+ .update(previous)
2045
+ .update('\u0000')
2046
+ .update(canonicalReplayJson(entry))
2047
+ .digest('hex');
2048
+ }
2049
+
2050
+ function canonicalReplayJson(value) {
2051
+ if (Array.isArray(value)) {
2052
+ return `[${value.map(canonicalReplayJson).join(',')}]`;
2053
+ }
2054
+ if (value !== null && typeof value === 'object') {
2055
+ return `{${Object.keys(value)
2056
+ .sort()
2057
+ .map(
2058
+ (key) =>
2059
+ `${JSON.stringify(key)}:${canonicalReplayJson(value[key])}`,
2060
+ )
2061
+ .join(',')}}`;
2062
+ }
2063
+ return JSON.stringify(value);
2064
+ }
2065
+
2066
+ function sanitizeReplayValue(value, path, ancestors) {
2067
+ if (
2068
+ value === null ||
2069
+ typeof value === 'string' ||
2070
+ typeof value === 'boolean'
2071
+ ) {
2072
+ return value;
2073
+ }
2074
+ if (typeof value === 'number') {
2075
+ if (!Number.isFinite(value)) {
2076
+ throw new TypeError(`${path} must contain a finite JSON number`);
2077
+ }
2078
+ if (Object.is(value, -0)) {
2079
+ throw new TypeError(`${path} must not contain negative zero`);
2080
+ }
2081
+ return value;
2082
+ }
2083
+ if (Array.isArray(value)) {
2084
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
2085
+ throw new TypeError(`${path} must be a plain JSON array`);
2086
+ }
2087
+ if (ancestors.has(value)) {
2088
+ throw new TypeError(`${path} must not contain a JSON cycle`);
2089
+ }
2090
+ const descriptors = Object.getOwnPropertyDescriptors(value);
2091
+ const keys = Reflect.ownKeys(descriptors);
2092
+ if (keys.some((key) => typeof key === 'symbol')) {
2093
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
2094
+ }
2095
+ const lengthDescriptor = descriptors.length;
2096
+ if (
2097
+ lengthDescriptor === undefined ||
2098
+ !Object.hasOwn(lengthDescriptor, 'value') ||
2099
+ !Number.isSafeInteger(lengthDescriptor.value) ||
2100
+ lengthDescriptor.value < 0
2101
+ ) {
2102
+ throw new TypeError(`${path} must be a plain JSON array`);
2103
+ }
2104
+ const length = lengthDescriptor.value;
2105
+ const nextAncestors = new Set(ancestors).add(value);
2106
+ const copy = [];
2107
+ for (let index = 0; index < length; index += 1) {
2108
+ const descriptor = descriptors[String(index)];
2109
+ if (
2110
+ descriptor === undefined ||
2111
+ !descriptor.enumerable ||
2112
+ !Object.hasOwn(descriptor, 'value')
2113
+ ) {
2114
+ throw new TypeError(`${path} must not be a sparse JSON array`);
839
2115
  }
840
- if (cause instanceof CaptainSessionLeaseActiveError) throw cause;
841
- throw new Error(
842
- `cannot acquire Captain session ${JSON.stringify(sessionId)} lease: ${errorMessage(cause)}`,
2116
+ copy.push(
2117
+ sanitizeReplayValue(
2118
+ descriptor.value,
2119
+ `${path}[${index}]`,
2120
+ nextAncestors,
2121
+ ),
843
2122
  );
844
2123
  }
845
- };
846
-
847
- return Object.freeze({ sessionsDir, read, latest, acquire });
2124
+ const extra = keys.find(
2125
+ (key) =>
2126
+ typeof key === 'string' &&
2127
+ key !== 'length' &&
2128
+ (!Number.isSafeInteger(Number(key)) ||
2129
+ Number(key) < 0 ||
2130
+ Number(key) >= length ||
2131
+ String(Number(key)) !== key),
2132
+ );
2133
+ if (extra !== undefined) {
2134
+ throw new TypeError(`${path}.${extra} is not a JSON array index`);
2135
+ }
2136
+ return Object.freeze(copy);
2137
+ }
2138
+ if (value === null || typeof value !== 'object') {
2139
+ throw new TypeError(`${path} must be a JSON value`);
2140
+ }
2141
+ const prototype = Object.getPrototypeOf(value);
2142
+ if (prototype !== Object.prototype && prototype !== null) {
2143
+ throw new TypeError(`${path} must be a JSON value`);
2144
+ }
2145
+ if (ancestors.has(value)) {
2146
+ throw new TypeError(`${path} must not contain a JSON cycle`);
2147
+ }
2148
+ const descriptors = Object.getOwnPropertyDescriptors(value);
2149
+ const keys = Reflect.ownKeys(descriptors);
2150
+ if (keys.some((key) => typeof key === 'symbol')) {
2151
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
2152
+ }
2153
+ const nextAncestors = new Set(ancestors).add(value);
2154
+ const copy = {};
2155
+ for (const key of keys) {
2156
+ if (typeof key !== 'string') continue;
2157
+ if (key === 'resumeToken') continue;
2158
+ const descriptor = descriptors[key];
2159
+ if (
2160
+ descriptor === undefined ||
2161
+ !descriptor.enumerable ||
2162
+ !Object.hasOwn(descriptor, 'value')
2163
+ ) {
2164
+ throw new TypeError(
2165
+ `${path}.${key} must be an enumerable JSON data property`,
2166
+ );
2167
+ }
2168
+ if (key === 'resume' && typeof descriptor.value === 'string') continue;
2169
+ if (descriptor.value === undefined) continue;
2170
+ Object.defineProperty(copy, key, {
2171
+ value: sanitizeReplayValue(
2172
+ descriptor.value,
2173
+ `${path}.${key}`,
2174
+ nextAncestors,
2175
+ ),
2176
+ enumerable: true,
2177
+ configurable: true,
2178
+ writable: true,
2179
+ });
2180
+ }
2181
+ return Object.freeze(copy);
848
2182
  }
849
2183
 
850
- function createLease({
2184
+ async function createLease({
851
2185
  sessionId,
852
2186
  owner,
2187
+ sessionsDir,
2188
+ replayPath,
2189
+ replayFs,
2190
+ readReplayStream,
853
2191
  readRecord,
854
2192
  writeRecord,
855
2193
  syncRecordDirectory,
@@ -893,6 +2231,19 @@ function createLease({
893
2231
 
894
2232
  const assertOwner = () => runExclusive(assertOwnerUnchecked);
895
2233
 
2234
+ const replayWriter = await createLeaseReplayWriter({
2235
+ sessionsDir,
2236
+ path: replayPath,
2237
+ fs: replayFs,
2238
+ assertOwner: assertOwnerUnchecked,
2239
+ readStream: readReplayStream,
2240
+ });
2241
+
2242
+ const finishSettlement = async (record) => {
2243
+ await replayWriter.checkpoint();
2244
+ return record;
2245
+ };
2246
+
896
2247
  const read = () =>
897
2248
  runExclusive(async () => {
898
2249
  await assertOwnerUnchecked();
@@ -1636,7 +2987,7 @@ function createLease({
1636
2987
  await assertOwnerUnchecked();
1637
2988
  await syncRecordDirectory();
1638
2989
  await assertOwnerUnchecked();
1639
- return current;
2990
+ return finishSettlement(current);
1640
2991
  }
1641
2992
  if (settledAbandonment.phase === 'final') {
1642
2993
  throw new Error(
@@ -1666,7 +3017,7 @@ function createLease({
1666
3017
  await assertOwnerUnchecked();
1667
3018
  await writeRecord(record, { noReplace: false });
1668
3019
  await assertOwnerUnchecked();
1669
- return record;
3020
+ return finishSettlement(record);
1670
3021
  }
1671
3022
  if (Object.hasOwn(prior.uncertain, 'abandonment')) {
1672
3023
  const abandonment = prior.uncertain.abandonment;
@@ -1724,7 +3075,7 @@ function createLease({
1724
3075
  await assertOwnerUnchecked();
1725
3076
  await writeRecord(record, { noReplace: false });
1726
3077
  await assertOwnerUnchecked();
1727
- return record;
3078
+ return finishSettlement(record);
1728
3079
  });
1729
3080
 
1730
3081
  const writeEffectLedger = (authorityValue, commandsValue) =>
@@ -1845,16 +3196,23 @@ function createLease({
1845
3196
  return record;
1846
3197
  });
1847
3198
 
1848
- const release = () =>
1849
- runExclusive(async () => {
3199
+ const release = () => {
3200
+ replayWriter.closeAppendAdmission();
3201
+ return runExclusive(async () => {
3202
+ await replayWriter.prepareRelease();
1850
3203
  const current = await assertOwnerUnchecked();
1851
3204
  await retireObservedLease(sessionId, current);
1852
3205
  released = true;
3206
+ return replayWriter.status();
1853
3207
  });
3208
+ };
1854
3209
 
1855
3210
  return Object.freeze({
1856
3211
  sessionId,
1857
3212
  ownerToken: owner.ownerToken,
3213
+ append: replayWriter.append,
3214
+ readStream: replayWriter.read,
3215
+ streamStatus: replayWriter.status,
1858
3216
  read,
1859
3217
  initializeSettledWithPredecessor,
1860
3218
  abandonFreshSettled,
@@ -3463,7 +4821,9 @@ function validateCaptainSessionProjection(
3463
4821
  structural
3464
4822
  ? ['id', 'adapter']
3465
4823
  : ['id', 'adapter', 'model', 'effort'],
3466
- ['instruction', 'permissions'],
4824
+ structural
4825
+ ? ['instruction', 'permissions']
4826
+ : ['instruction', 'permissions', 'fastMode'],
3467
4827
  playerPath,
3468
4828
  );
3469
4829
  assertPlayerId(player.id, `${playerPath}.id`);
@@ -3559,15 +4919,17 @@ function validateCaptainSessionProjection(
3559
4919
  for (const roleId of requiredRoleIds) {
3560
4920
  const bindingPath = `${itemPath}.roles.${roleId}`;
3561
4921
  const binding = requireRecord(roles[roleId], bindingPath);
3562
- rejectUnknownOrMissingKeys(
4922
+ exactOptionalKeys(
3563
4923
  binding,
3564
4924
  structural
3565
4925
  ? ['playerId']
3566
4926
  : ['playerId', 'model', 'effort'],
4927
+ structural ? [] : ['fastMode'],
3567
4928
  bindingPath,
3568
4929
  );
3569
4930
  assertPlayerId(binding.playerId, `${bindingPath}.playerId`);
3570
4931
  if (!structural) {
4932
+ validateProjectedFastMode(binding, `${bindingPath}.fastMode`);
3571
4933
  validateTuningSelection(binding.model, `${bindingPath}.model`);
3572
4934
  const player = projection.players.find(
3573
4935
  (candidate) => candidate.id === binding.playerId,
@@ -3604,13 +4966,27 @@ function validateCaptainSessionProjection(
3604
4966
  }
3605
4967
  }
3606
4968
 
4969
+ // Absence is the canonical provider default, so the key stays optional and
4970
+ // the structural projection erases it beside model and effort.
4971
+ function validateProjectedFastMode(record, path) {
4972
+ if (
4973
+ Object.hasOwn(record, 'fastMode') &&
4974
+ typeof record.fastMode !== 'boolean'
4975
+ ) {
4976
+ throw new Error(`${path} must be a boolean`);
4977
+ }
4978
+ }
4979
+
3607
4980
  function validateProjectedAgent(value, path, { structural, hasId = false }) {
3608
4981
  const agent = requireRecord(value, path);
4982
+ if (!structural) validateProjectedFastMode(agent, `${path}.fastMode`);
3609
4983
  if (!hasId) {
3610
4984
  exactOptionalKeys(
3611
4985
  agent,
3612
4986
  structural ? ['adapter'] : ['adapter', 'model', 'effort'],
3613
- ['instruction', 'permissions'],
4987
+ structural
4988
+ ? ['instruction', 'permissions']
4989
+ : ['instruction', 'permissions', 'fastMode'],
3614
4990
  path,
3615
4991
  );
3616
4992
  }