@sublang/playbook 17.11.0 → 18.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.
@@ -52,6 +52,9 @@ import {
52
52
  resolveMachineIdentity,
53
53
  } from './machine-identity.js';
54
54
  import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
55
+ import { createSessionChunks } from './session-chunks.js';
56
+ import { createBoundedReplay } from './session-replay.js';
57
+ import { decodeStoredManifest, encodeStoredManifest, SESSION_STORAGE_VERSION } from './storage-manifest.js';
55
58
 
56
59
  export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 6;
57
60
  export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
@@ -301,332 +304,70 @@ export function sanitizeReplayRecord(value) {
301
304
  return requireRecord(sanitized, 'replay record');
302
305
  }
303
306
 
304
- // PBCLI-75/76/83: one lease-owned writer repairs the retained prefix, queues
307
+ // PBCLI-75/76/83: one lease-owned writer verifies the retained prefix, queues
305
308
  // appends, and keeps replay durability fail-soft beside canonical settlement.
306
- async function createLeaseReplayWriter({
307
- sessionsDir,
308
- path,
309
- fs,
310
- assertOwner,
311
- readStream,
312
- }) {
313
- let lastReadableSeq = null;
314
- let lastDurableSeq = null;
315
- let incomplete = true;
316
- let identity;
317
- let completeOffset = 0;
318
- let writerHandle;
319
- let operationTail = Promise.resolve();
320
- let appendAdmissionClosed = false;
321
-
322
- const status = () =>
323
- Object.freeze({ lastReadableSeq, lastDurableSeq, incomplete });
324
-
325
- const enqueue = (operation) => {
326
- const result = operationTail.then(operation);
327
- operationTail = result.then(
328
- () => undefined,
329
- () => undefined,
330
- );
331
- return result;
332
- };
333
-
334
- const latch = () => {
335
- if (lastReadableSeq !== null) incomplete = true;
336
- };
337
-
338
- const updateReadableFromDisk = async () => {
339
- if (lastReadableSeq === null) return;
340
- try {
341
- const snapshot = await readReplayWriterSnapshot({
342
- sessionsDir,
343
- path,
344
- identity,
345
- fs,
346
- });
347
- if (snapshot.absent) return;
348
- const parsed = parseReplayLines(
349
- snapshot.bytes,
350
- 0,
351
- EMPTY_REPLAY_PREFIX_DIGEST,
352
- );
353
- lastReadableSeq = parsed.sequence;
354
- completeOffset = parsed.completeBytes;
355
- identity = snapshot.identity;
356
- } catch {
357
- // The already-established boundary remains the only trustworthy one.
358
- }
359
- };
360
-
361
- const initialize = async () => {
362
- let snapshot;
363
- await assertOwner();
364
- try {
365
- snapshot = await readReplayWriterSnapshot({
366
- sessionsDir,
367
- path,
368
- fs,
369
- });
370
- if (snapshot.absent) {
371
- lastReadableSeq = 0;
372
- lastDurableSeq = 0;
373
- incomplete = false;
374
- completeOffset = 0;
375
- return;
376
- }
377
-
378
- const parsed = parseReplayLines(
379
- snapshot.bytes,
380
- 0,
381
- EMPTY_REPLAY_PREFIX_DIGEST,
382
- );
383
- const prefixSequence = parsed.sequence;
384
- lastReadableSeq = prefixSequence;
385
- lastDurableSeq = prefixSequence;
386
- incomplete = false;
387
- identity = snapshot.identity;
388
- completeOffset = parsed.completeBytes;
389
- const tail = snapshot.bytes.subarray(parsed.completeBytes);
390
- if (tail.length === 0) return;
391
-
392
- let retainTail = false;
393
- try {
394
- parseReplayEnvelope(tail, prefixSequence + 1);
395
- retainTail = true;
396
- } catch {
397
- // Every other torn tail is discarded at the complete-prefix boundary.
398
- }
399
-
400
- if (retainTail) {
401
- try {
402
- writerHandle = await mutateReplayWriterFile({
403
- sessionsDir,
404
- path,
405
- identity,
406
- expectedSize: snapshot.bytes.length,
407
- expectedFinalSize: snapshot.bytes.length + 1,
408
- fs,
409
- retainHandle: true,
410
- operation: async (handle) => {
411
- await writeReplayRange(
412
- handle,
413
- Buffer.from('\n'),
414
- snapshot.bytes.length,
415
- );
416
- completeOffset = snapshot.bytes.length + 1;
417
- lastReadableSeq = prefixSequence + 1;
418
- await handle.sync();
419
- },
420
- });
421
- lastDurableSeq = prefixSequence + 1;
422
- } catch {
423
- await updateReadableFromDisk();
424
- latch();
425
- }
426
- return;
427
- }
428
-
429
- try {
430
- writerHandle = await mutateReplayWriterFile({
431
- sessionsDir,
432
- path,
433
- identity,
434
- expectedSize: snapshot.bytes.length,
435
- expectedFinalSize: parsed.completeBytes,
436
- fs,
437
- retainHandle: true,
438
- operation: async (handle) => {
439
- await handle.truncate(parsed.completeBytes);
440
- completeOffset = parsed.completeBytes;
441
- await handle.sync();
442
- },
443
- });
444
- } catch {
445
- await updateReadableFromDisk();
446
- latch();
447
- }
448
- } catch {
449
- lastReadableSeq = null;
450
- lastDurableSeq = null;
451
- incomplete = true;
452
- identity = undefined;
453
- completeOffset = 0;
454
- }
309
+ // Bounded physical replay preserves the public readable/durable distinction.
310
+ // A failed append latches history incompleteness; recovery writes remain open.
311
+ async function createLeaseReplayWriter({ sessionsDir, path, fs, assertOwner, readStream, chunks }) {
312
+ const sessionId = path.slice(path.lastIndexOf('/') + 1, -'.records.jsonl'.length);
313
+ const backend = await createBoundedReplay({ sessionId, sessionsDir, fsOps: fs, assertOwner, chunks });
314
+ let lastReadableSeq = null, lastDurableSeq = null, incomplete = true, closed = false;
315
+ let tail = Promise.resolve();
316
+ const enqueue = (operation) => { const result = tail.then(operation); tail = result.catch(() => {}); return result; };
317
+ const status = () => Object.freeze({ lastReadableSeq, lastDurableSeq, incomplete });
318
+ const refresh = async () => {
319
+ const snapshot = await backend.readSnapshot();
320
+ const history = parseSessionHistory(snapshot.bytes);
321
+ if (history.incomplete || history.pendingTail) throw new Error('bounded replay is incomplete');
322
+ return history.lastReadableSeq;
455
323
  };
456
-
457
- const appendAtQueueHead = async (record, role) => {
458
- if (lastReadableSeq === null || incomplete) return undefined;
459
-
460
- let sanitized;
461
- let sanitizationFailed = false;
462
- let sanitizationFailure;
463
- try {
464
- sanitized = sanitizeReplayRecord(record);
465
- } catch (cause) {
466
- sanitizationFailed = true;
467
- sanitizationFailure = cause;
468
- }
469
- // Sanitization may invoke caller-controlled Proxy traps. This one owner
470
- // check is the append's cooperative-lease linearization point.
471
- await assertOwner();
472
- if (sanitizationFailed) {
473
- latch();
474
- throw sanitizationFailure;
475
- }
476
-
477
- if (lastReadableSeq >= Number.MAX_SAFE_INTEGER) {
478
- latch();
479
- throw new Error('replay stream sequence exhausted its safe integer range');
480
- }
481
- const sequence = lastReadableSeq + 1;
482
- const line = Buffer.from(
483
- `${JSON.stringify({
484
- v: RECORDS_STREAM_VERSION,
485
- seq: sequence,
486
- ...(role === undefined ? {} : { role }),
487
- record: sanitized,
488
- })}\n`,
489
- );
490
- const publishing = identity === undefined;
491
- try {
492
- if (publishing) {
493
- const published = await publishReplayWriterFile({
494
- sessionsDir,
495
- path,
496
- bytes: line,
497
- fs,
498
- });
499
- identity = published.identity;
500
- writerHandle = published.handle;
501
- completeOffset = line.length;
502
- lastReadableSeq = sequence;
503
- await syncDirectory(sessionsDir, fs);
504
- } else if (writerHandle === undefined) {
505
- writerHandle = await mutateReplayWriterFile({
506
- sessionsDir,
507
- path,
508
- identity,
509
- expectedSize: completeOffset,
510
- expectedFinalSize: completeOffset + line.length,
511
- fs,
512
- retainHandle: true,
513
- operation: (handle) =>
514
- writeReplayRange(handle, line, completeOffset),
515
- });
516
- completeOffset += line.length;
517
- lastReadableSeq = sequence;
518
- } else {
519
- await appendReplayWriterFile({
520
- handle: writerHandle,
521
- identity,
522
- expectedSize: completeOffset,
523
- bytes: line,
524
- });
525
- completeOffset += line.length;
526
- lastReadableSeq = sequence;
527
- }
528
- return undefined;
529
- } catch (cause) {
530
- await updateReadableFromDisk();
531
- latch();
532
- throw cause;
533
- }
324
+ try { await assertOwner(); lastReadableSeq = lastDurableSeq = await refresh(); incomplete = false; }
325
+ catch { /* Read failures cannot authorize new calls or overwrite evidence. */ }
326
+ const latch = async () => {
327
+ incomplete = true;
328
+ try { lastReadableSeq = await refresh(); } catch { /* Keep the proven prefix. */ }
534
329
  };
535
-
536
330
  const append = (record, role) => {
537
- if (appendAdmissionClosed) {
538
- return Promise.reject(
539
- new Error('replay append admission is closed for release'),
540
- );
541
- }
542
- if (lastReadableSeq === null || incomplete) return Promise.resolve();
543
- try {
544
- assertReplayAppendArguments(record, role);
545
- } catch (cause) {
546
- return Promise.reject(cause);
547
- }
548
- return enqueue(() => appendAtQueueHead(record, role));
549
- };
550
-
551
- const read = (options) => {
552
- if (appendAdmissionClosed) {
553
- return Promise.reject(new Error('replay writer is closing'));
554
- }
331
+ if (closed) return Promise.reject(new Error('replay append admission is closed for release'));
332
+ if (incomplete || lastReadableSeq === null) return Promise.resolve();
333
+ try { assertReplayAppendArguments(record, role); } catch (cause) { return Promise.reject(cause); }
555
334
  return enqueue(async () => {
335
+ if (lastReadableSeq === null || incomplete) return;
336
+ // Sanitizer access can run caller code. Ownership refusal remains an
337
+ // admission failure even if that code changed the canonical lease.
338
+ let sanitized, sanitizationFailure, sanitizationFailed = false;
339
+ try { sanitized = sanitizeReplayRecord(record); } catch (cause) { sanitizationFailed = true; sanitizationFailure = cause; }
556
340
  await assertOwner();
557
- if (lastReadableSeq === null) {
558
- throw new Error('replay stream is unavailable for this lease');
559
- }
560
- const result = await readStream(options);
561
- lastReadableSeq = result.lastReadableSeq;
562
- return freezeReplayLeaseReadResult(
563
- result.entries,
564
- lastReadableSeq,
565
- lastDurableSeq,
566
- incomplete,
567
- );
341
+ try {
342
+ if (sanitizationFailed) throw sanitizationFailure;
343
+ if (lastReadableSeq >= Number.MAX_SAFE_INTEGER) throw new Error('replay stream sequence exhausted its safe integer range');
344
+ const seq = lastReadableSeq + 1;
345
+ await backend.appendBytes(Buffer.from(`${JSON.stringify({v: RECORDS_STREAM_VERSION, seq, ...(role === undefined ? {} : {role}), record: sanitized})}\n`));
346
+ lastReadableSeq = seq;
347
+ } catch (cause) { await latch(); throw cause; }
568
348
  });
569
349
  };
570
-
571
- const checkpointAtQueueHead = async () => {
572
- if (
573
- lastReadableSeq === null ||
574
- incomplete ||
575
- lastReadableSeq === lastDurableSeq
576
- ) {
577
- return;
578
- }
579
- try {
580
- await assertOwner();
581
- if (writerHandle === undefined) {
582
- throw new Error('replay stream checkpoint has no writer handle');
583
- }
584
- await checkpointReplayWriterFile({
585
- sessionsDir,
586
- path,
587
- handle: writerHandle,
588
- identity,
589
- expectedSize: completeOffset,
590
- fs,
591
- });
592
- await assertOwner();
593
- lastDurableSeq = lastReadableSeq;
594
- } catch {
595
- await updateReadableFromDisk();
596
- latch();
597
- }
598
- };
599
-
600
- const checkpoint = () => enqueue(checkpointAtQueueHead);
601
-
602
- const closeAppendAdmission = () => {
603
- appendAdmissionClosed = true;
350
+ const checkpointNow = async () => {
351
+ if (incomplete || lastReadableSeq === null) return;
352
+ try { await assertOwner(); await backend.checkpoint({trustPublished: true}); await assertOwner(); lastDurableSeq = lastReadableSeq; }
353
+ catch { await latch(); }
604
354
  };
605
-
606
- const prepareRelease = () =>
607
- enqueue(async () => {
608
- try {
609
- await checkpointAtQueueHead();
610
- } finally {
611
- const handle = writerHandle;
612
- writerHandle = undefined;
613
- try {
614
- await handle?.close();
615
- } catch {
616
- // A completed checkpoint owns durability; descriptor cleanup cannot
617
- // broaden the latch trigger set or block canonical lease retirement.
618
- }
619
- }
620
- });
621
-
622
- await initialize();
623
355
  return Object.freeze({
624
356
  append,
625
- read,
357
+ read(options) {
358
+ if (closed) return Promise.reject(new Error('replay writer is closing'));
359
+ return enqueue(async () => {
360
+ await assertOwner();
361
+ if (lastReadableSeq === null) throw new Error('replay stream is unavailable for this lease');
362
+ const result = await readStream(options);
363
+ lastReadableSeq = result.lastReadableSeq;
364
+ return freezeReplayLeaseReadResult(result.entries, lastReadableSeq, lastDurableSeq, incomplete);
365
+ });
366
+ },
626
367
  status,
627
- checkpoint,
628
- closeAppendAdmission,
629
- prepareRelease,
368
+ checkpoint: () => enqueue(checkpointNow),
369
+ closeAppendAdmission: () => { closed = true; },
370
+ prepareRelease: () => enqueue(checkpointNow),
630
371
  });
631
372
  }
632
373
 
@@ -670,6 +411,14 @@ export function createCaptainSessionStore(options = {}) {
670
411
  const replayReadCursors = new Map();
671
412
  const replayReadQueues = new Map();
672
413
  const portableWriters = new Map();
414
+ const chunkStores = new Map();
415
+ const chunksFor = (sessionId) => {
416
+ assertSessionId(sessionId);
417
+ // Chunk provisioning creates only this owned child; the codec verifies
418
+ // the existing sessions parent and never creates it or its ancestors.
419
+ if (!chunkStores.has(sessionId)) chunkStores.set(sessionId, createSessionChunks({ directory: join(sessionsDir, `${sessionId}.chunks`), fsOps: fs }));
420
+ return chunkStores.get(sessionId);
421
+ };
673
422
  const assetStores = new Map();
674
423
  const assetStoreFor = (sessionId) => {
675
424
  assertSessionId(sessionId);
@@ -731,8 +480,10 @@ export function createCaptainSessionStore(options = {}) {
731
480
  );
732
481
  }
733
482
  let value;
483
+ let storageVersion;
734
484
  try {
735
- value = JSON.parse(text);
485
+ storageVersion = JSON.parse(text)?.schemaVersion;
486
+ value = await decodeStoredManifest(text, chunksFor(sessionId), sessionId);
736
487
  } catch (cause) {
737
488
  throw new Error(
738
489
  `Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} is not valid JSON: ${errorMessage(cause)}`,
@@ -747,8 +498,8 @@ export function createCaptainSessionStore(options = {}) {
747
498
  record = value.schemaVersion === SESSION_MANIFEST_VERSION
748
499
  ? recoveryFromManifest(value)
749
500
  : validateCaptainSessionRecord(value);
750
- if (requirePortable && value.schemaVersion !== SESSION_MANIFEST_VERSION) {
751
- throw new CaptainSessionRecordNonresumableError(value.schemaVersion, `schema ${value.schemaVersion} requires explicit migration; run playbook migrate-session ${sessionId}`, captainSessionOrderingBoundary(record));
501
+ if (requirePortable && storageVersion !== SESSION_STORAGE_VERSION) {
502
+ throw new CaptainSessionRecordNonresumableError(storageVersion, `storage schema ${storageVersion} requires explicit migration; run playbook migrate-session ${sessionId}`, captainSessionOrderingBoundary(record));
752
503
  }
753
504
  } catch (cause) {
754
505
  const context =
@@ -793,22 +544,27 @@ export function createCaptainSessionStore(options = {}) {
793
544
  return record;
794
545
  };
795
546
 
796
- const readManifest = async (sessionId) => {
547
+ const readStoredManifest = async (sessionId) => {
797
548
  let bytes;
798
549
  try { await assertPrivateDirectory(sessionsDir, fs); bytes = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record'); }
799
550
  catch (cause) { if (cause?.code === 'ENOENT') throw new CaptainSessionNotFoundError(sessionId, recordPathFor(sessionId)); throw cause; }
800
- const value = JSON.parse(bytes);
551
+ const value = await decodeStoredManifest(bytes, chunksFor(sessionId), sessionId);
801
552
  if (value.sessionId !== sessionId) throw new Error('session manifest identity does not match its filename');
802
- return value.schemaVersion === 7 ? validateSessionManifest(value) : value;
553
+ return { manifest: value.schemaVersion === 7 ? validateSessionManifest(value) : value,
554
+ storageVersion: JSON.parse(bytes).schemaVersion };
803
555
  };
556
+ const readManifest = async (sessionId) => (await readStoredManifest(sessionId)).manifest;
804
557
  const prepare = async () => {
805
558
  await prepareSessionPermissions(sessionsDir, fs);
806
559
  let names; try { names = await fs.readdir(sessionsDir); } catch (cause) { if (cause?.code === 'ENOENT') return; throw cause; }
807
- for (const name of names) if (name.endsWith('.assets') && SESSION_ID_PATTERN.test(name.slice(0, -7))) await assetStoreFor(name.slice(0, -7)).prepare();
560
+ for (const name of names) {
561
+ if (name.endsWith('.assets') && SESSION_ID_PATTERN.test(name.slice(0, -7))) await assetStoreFor(name.slice(0, -7)).prepare();
562
+ if (name.endsWith('.chunks') && SESSION_ID_PATTERN.test(name.slice(0, -7))) await chunksFor(name.slice(0, -7)).prepare();
563
+ }
808
564
  };
809
565
  const readHistory = async (sessionId, options = {}) => {
810
566
  assertSessionId(sessionId);
811
- const history = await readSessionHistory({ sessionsDir, path: recordsPathFor(sessionId), fs, afterSeq: options.afterSeq ?? 0 });
567
+ const history = await readSessionHistory({ sessionsDir, path: recordsPathFor(sessionId), fs, chunks: chunksFor(sessionId), afterSeq: options.afterSeq ?? 0 });
812
568
  if (!history.missing) return history;
813
569
  let legacy;
814
570
  try {
@@ -848,9 +604,10 @@ export function createCaptainSessionStore(options = {}) {
848
604
  }
849
605
  };
850
606
  const validate = async (sessionId, context = {}) => {
851
- const manifest = await readManifest(sessionId);
607
+ const { manifest, storageVersion } = await readStoredManifest(sessionId);
852
608
  const history = await readHistory(sessionId);
853
609
  const reasons = [];
610
+ if (storageVersion === 7) reasons.push(`storage schema 7 requires explicit migration; run playbook migrate-session ${sessionId}`);
854
611
  let integrityValid = !history.missing && !history.incomplete;
855
612
  if (history.missing) reasons.push('session replay file is missing');
856
613
  if (history.pendingTail) reasons.push('session replay has an unfinished final record');
@@ -908,7 +665,7 @@ export function createCaptainSessionStore(options = {}) {
908
665
  path: recordsPathFor(sessionId),
909
666
  afterSeq,
910
667
  cursor: replayReadCursors.get(sessionId),
911
- fs,
668
+ fs, chunks: chunksFor(sessionId),
912
669
  }),
913
670
  );
914
671
  const drained = operation.then(
@@ -1123,6 +880,7 @@ export function createCaptainSessionStore(options = {}) {
1123
880
  await assertPrivateRegularPath(destination, 0o600, fs, 'record');
1124
881
  }
1125
882
 
883
+ const encoded = await encodeStoredManifest(record, chunksFor(record.sessionId));
1126
884
  const tempId = createTempId();
1127
885
  assertUuid(tempId, 'Captain session temporary id');
1128
886
  const temporary = join(
@@ -1140,7 +898,7 @@ export function createCaptainSessionStore(options = {}) {
1140
898
  if (!tempStat.isFile() || (tempStat.mode & 0o7777) !== 0o600) {
1141
899
  throw new Error('Captain session temporary path is not a private regular file');
1142
900
  }
1143
- await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8');
901
+ await handle.writeFile(encoded);
1144
902
  await handle.sync();
1145
903
  await handle.close();
1146
904
  handle = undefined;
@@ -1211,11 +969,15 @@ export function createCaptainSessionStore(options = {}) {
1211
969
  catch (cause) { if (cause?.code !== 'ENOTEMPTY' && cause?.code !== 'EEXIST') throw cause; }
1212
970
  await syncDirectory(sessionsDir, fs);
1213
971
  } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1214
- for (const suffix of ['.records.jsonl', '.hints.json', '.spex.json', '.json']) {
972
+ for (const suffix of ['.hints.json', '.spex.json', '.records.jsonl', '.replay.json']) {
1215
973
  const path = join(sessionsDir, `${sessionId}${suffix}`);
1216
974
  try { await assertPrivateRegularPath(path, 0o600, fs, 'session file'); await fs.unlink(path); await syncDirectory(sessionsDir, fs); }
1217
975
  catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1218
976
  }
977
+ await chunksFor(sessionId).delete();
978
+ const manifestPath = recordPathFor(sessionId);
979
+ try { await assertPrivateRegularPath(manifestPath, 0o600, fs, 'session file'); await fs.unlink(manifestPath); await syncDirectory(sessionsDir, fs); }
980
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1219
981
  };
1220
982
 
1221
983
  const readLeaseDirectory = async (
@@ -1503,6 +1265,18 @@ export function createCaptainSessionStore(options = {}) {
1503
1265
  assertSessionId(sessionId);
1504
1266
  await prepareSessionPermissions(sessionsDir, fs, sessionId);
1505
1267
  await assetStoreFor(sessionId).prepare();
1268
+ try { await fs.lstat(join(sessionsDir, `${sessionId}.chunks`)); await chunksFor(sessionId).prepare(); }
1269
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1270
+ if (!management) {
1271
+ let stored;
1272
+ try { stored = JSON.parse(await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record')); }
1273
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1274
+ if (stored?.schemaVersion === 7) throw new Error(`session ${sessionId} requires bounded storage migration; run playbook migrate-session ${sessionId}`);
1275
+ if (stored === undefined) {
1276
+ try { await fs.lstat(recordsPathFor(sessionId)); throw new Error(`session ${sessionId} has legacy replay requiring explicit migration`); }
1277
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1278
+ }
1279
+ }
1506
1280
  let stage;
1507
1281
  let stagePublished = false;
1508
1282
  try {
@@ -1531,6 +1305,18 @@ export function createCaptainSessionStore(options = {}) {
1531
1305
  await publishLeaseStage(sessionId, stage, () => {
1532
1306
  stagePublished = true;
1533
1307
  });
1308
+ let chunkDirectory;
1309
+ try { chunkDirectory = await fs.lstat(join(sessionsDir, `${sessionId}.chunks`)); }
1310
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1311
+ if (chunkDirectory) {
1312
+ const assertPublishedOwner = async () => {
1313
+ const current = await readLeaseOwner(sessionId);
1314
+ if (current.ownerToken !== stage.owner.ownerToken) throw new Error('session ownership changed before chunk recovery');
1315
+ };
1316
+ await assertPublishedOwner();
1317
+ await chunksFor(sessionId).prepare({ recoverInterrupted: true });
1318
+ await assertPublishedOwner();
1319
+ }
1534
1320
  if (management) {
1535
1321
  let released = false;
1536
1322
  const assertOwner = async () => {
@@ -1559,6 +1345,7 @@ export function createCaptainSessionStore(options = {}) {
1559
1345
  validateSession: validate,
1560
1346
  portableWriters,
1561
1347
  assetStore: assetStoreFor(sessionId),
1348
+ chunks: chunksFor(sessionId),
1562
1349
  writeRecord,
1563
1350
  syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
1564
1351
  deleteRecord,
@@ -1596,9 +1383,131 @@ export function createCaptainSessionStore(options = {}) {
1596
1383
  }
1597
1384
  };
1598
1385
 
1386
+ const retainedMigrationInputs = async (receipt, sessionId, sourcePath, inputsDir) => {
1387
+ const replayPath = join(dirname(sourcePath), `${sessionId}.records.jsonl`);
1388
+ if (receipt?.v !== 1 || receipt.id !== sessionId || !Array.isArray(receipt.inputs) ||
1389
+ ![1, 2].includes(receipt.inputs.length) || receipt.inputs[0]?.path !== sourcePath ||
1390
+ receipt.inputs.length === 2 && receipt.inputs[1]?.path !== replayPath) throw new Error('migration receipt differs from its original source');
1391
+ const retained = [];
1392
+ for (const [index, input] of receipt.inputs.entries()) {
1393
+ const bytes = await readPrivateRegularFile(join(inputsDir, String(index)), 0o600, fs, 'retained migration input', true);
1394
+ if (typeof input.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(input.sha256) || sha256(bytes) !== input.sha256) throw new Error('retained migration input differs');
1395
+ retained.push(bytes);
1396
+ }
1397
+ return retained;
1398
+ };
1399
+
1400
+ const migrateBounded = async (sessionId, migration) => {
1401
+ // Unchanged format/cwd refusals must create no ownership or derived
1402
+ // directories. Acquisition is followed by authoritative validation again.
1403
+ validateSessionManifest(await readManifest(sessionId));
1404
+ const lease = await acquire(sessionId, true);
1405
+ let failure;
1406
+ try {
1407
+ await lease.assertOwner();
1408
+ const source = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'migration source', true);
1409
+ const raw = JSON.parse(source);
1410
+ const backup = join(migration.backupDir ?? join(dirname(sessionsDir), 'local', 'migrations', sessionId), 'bounded');
1411
+ const receiptPath = join(backup, 'receipt.json');
1412
+ let receipt;
1413
+ try { receipt = JSON.parse(await readPrivateRegularFile(receiptPath, 0o600, fs, 'migration receipt')); }
1414
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1415
+ if (raw.schemaVersion === SESSION_STORAGE_VERSION) {
1416
+ const current = await validate(sessionId);
1417
+ if (!current.integrityValid) throw new Error('completed bounded migration failed validation');
1418
+ const originalBackup = migration.backupDir ?? join(dirname(sessionsDir), 'local', 'migrations', sessionId);
1419
+ const originalReceiptPath = join(originalBackup, 'receipt.json');
1420
+ let originalReceipt;
1421
+ try { originalReceipt = JSON.parse(await readPrivateRegularFile(originalReceiptPath, 0o600, fs, 'original migration receipt')); }
1422
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1423
+ if (originalReceipt?.complete === false) {
1424
+ if (originalReceipt.v !== 1 || originalReceipt.id !== sessionId ||
1425
+ !Array.isArray(originalReceipt.inputs) || ![1, 2].includes(originalReceipt.inputs.length) ||
1426
+ originalReceipt.inputs[0]?.path !== recordPathFor(sessionId) ||
1427
+ !/^[0-9a-f]{64}$/.test(originalReceipt.publishedRootSha256) ||
1428
+ originalReceipt.publishedRootSha256 !== sha256(source)) {
1429
+ throw new Error('incomplete original migration cannot prove its published root; retry with its original source');
1430
+ }
1431
+ const [retainedSource, retainedReplay] = await retainedMigrationInputs(originalReceipt, sessionId, recordPathFor(sessionId), join(originalBackup, 'inputs'));
1432
+ const old = JSON.parse(retainedSource);
1433
+ if (![2, 3, 4, 5, 6].includes(old.schemaVersion) || old.sessionId !== sessionId) throw new Error('retained original migration source differs');
1434
+ try { validateCaptainSessionRecord(old); }
1435
+ catch (cause) { if (!(cause instanceof CaptainSessionRecordNonresumableError)) throw cause; }
1436
+ await lease.assertOwner();
1437
+ if (!(await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'published migration root', true)).equals(source)) throw new Error('published migration root changed during completion');
1438
+ try {
1439
+ const legacy = await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'remaining original migration replay', true);
1440
+ if (retainedReplay === undefined || !legacy.equals(retainedReplay)) throw new Error('legacy replay differs from retained original migration input');
1441
+ await fs.unlink(recordsPathFor(sessionId)); await syncDirectory(sessionsDir, fs);
1442
+ } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1443
+ await writePrivateJson(originalReceiptPath, {...originalReceipt, complete: true}, fs);
1444
+ }
1445
+ if (receipt?.complete === false) {
1446
+ const retained = await readPrivateRegularFile(join(backup, 'replay'), 0o600, fs, 'retained replay', true);
1447
+ const retainedManifest = await readPrivateRegularFile(join(backup, 'manifest'), 0o600, fs, 'retained manifest', true);
1448
+ if (receipt.sessionId !== sessionId || sha256(retained) !== receipt.replaySha256 ||
1449
+ sha256(retainedManifest) !== receipt.manifestSha256 ||
1450
+ !/^[0-9a-f]{64}$/.test(receipt.publishedRootSha256) ||
1451
+ sha256(source) !== receipt.publishedRootSha256) throw new Error('bounded migration receipt differs');
1452
+ try {
1453
+ const legacy = await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'legacy replay', true);
1454
+ if (!legacy.equals(retained)) throw new Error('legacy replay changed after migration');
1455
+ await fs.unlink(recordsPathFor(sessionId)); await syncDirectory(sessionsDir, fs);
1456
+ } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1457
+ await writePrivateJson(receiptPath, {...receipt, complete: true}, fs);
1458
+ }
1459
+ return {manifest: current.manifest, migrated: false, reasons: current.reasons};
1460
+ }
1461
+ const manifest = validateSessionManifest(raw);
1462
+ const current = await validate(sessionId);
1463
+ if (!current.integrityValid) throw new Error('legacy bundle is incomplete or corrupt; bounded migration refused');
1464
+ const replay = await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'migration replay', true);
1465
+ const history = parseSessionHistory(replay);
1466
+ let convertedReplay = replay;
1467
+ if (history.pendingTail) {
1468
+ const prefix = replay.subarray(0, history.completeBytes);
1469
+ try {
1470
+ const entry = parseReplayEnvelope(replay.subarray(history.completeBytes), history.lastReadableSeq + 1);
1471
+ const record = sanitizeReplayRecord(entry.record);
1472
+ convertedReplay = isDeepStrictEqual(record, entry.record)
1473
+ ? Buffer.concat([replay, Buffer.from('\n')])
1474
+ : Buffer.concat([prefix, Buffer.from(`${JSON.stringify({...entry, record})}\n`)]);
1475
+ } catch { convertedReplay = prefix; }
1476
+ }
1477
+ await ensurePrivateDirectory(backup, fs, mayCreateDirectory);
1478
+ for (const [name, bytes] of [['manifest', source], ['replay', replay]]) {
1479
+ const path = join(backup, name);
1480
+ try { if (!(await readPrivateRegularFile(path, 0o600, fs, 'retained migration input', true)).equals(bytes)) throw new Error('retained bounded migration input differs'); }
1481
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; await writePrivateBytes(path, bytes, fs); }
1482
+ }
1483
+ const encoded = await encodeStoredManifest(manifest, chunksFor(sessionId));
1484
+ const expected = {v: 1, sessionId, manifestSha256: sha256(source), replaySha256: sha256(replay), publishedRootSha256: sha256(encoded), complete: false};
1485
+ if (receipt && (receipt.sessionId !== sessionId || receipt.manifestSha256 !== expected.manifestSha256 || receipt.replaySha256 !== expected.replaySha256)) throw new Error('bounded migration receipt differs');
1486
+ await writePrivateJson(receiptPath, expected, fs);
1487
+ const backend = await createBoundedReplay({sessionId, sessionsDir, fsOps: fs, assertOwner: lease.assertOwner, chunks: chunksFor(sessionId)});
1488
+ const snapshot = await backend.readSnapshot();
1489
+ if (snapshot.root === null) await backend.initializeBytes(convertedReplay);
1490
+ else if (!snapshot.bytes.equals(convertedReplay)) throw new Error('bounded migration replay differs');
1491
+ await lease.assertOwner();
1492
+ await writeRecord(manifest, {portable: true, noReplace: false});
1493
+ const published = await validate(sessionId);
1494
+ if (!published.integrityValid) throw new Error('published bounded migration failed validation');
1495
+ await lease.assertOwner();
1496
+ if (!(await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'migration replay', true)).equals(replay)) throw new Error('legacy replay changed during migration');
1497
+ await fs.unlink(recordsPathFor(sessionId)); await syncDirectory(sessionsDir, fs);
1498
+ await writePrivateJson(receiptPath, {...expected, complete: true}, fs);
1499
+ return {manifest, migrated: true, reasons: published.reasons};
1500
+ } catch (cause) { failure = {cause}; throw cause; }
1501
+ finally { await releaseInOrder([lease], failure, 'cannot finish bounded session migration'); }
1502
+ };
1503
+
1599
1504
  const migrate = async (sessionId, migration = {}) => {
1600
1505
  assertSessionId(sessionId);
1601
1506
  await prepareSessionPermissions(sessionsDir, fs, sessionId, true);
1507
+ if (migration.sourcePath === undefined || migration.sourcePath === recordPathFor(sessionId)) {
1508
+ const candidate = JSON.parse(await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'migration source'));
1509
+ if (candidate.schemaVersion === 7 || candidate.schemaVersion === SESSION_STORAGE_VERSION) return migrateBounded(sessionId, migration);
1510
+ }
1602
1511
  const sourcePath = migration.sourcePath ?? recordPathFor(sessionId);
1603
1512
  const sidecar = sourcePath.endsWith('.spex.json');
1604
1513
  const sourceDir = dirname(sourcePath);
@@ -1610,7 +1519,7 @@ export function createCaptainSessionStore(options = {}) {
1610
1519
  const receiptPath = join(backupDir, 'receipt.json');
1611
1520
  const decodeSource = (sourceBytes) => {
1612
1521
  const source = JSON.parse(sourceBytes);
1613
- if (!sidecar && source.schemaVersion === 7) {
1522
+ if (!sidecar && (source.schemaVersion === 7 || source.schemaVersion === SESSION_STORAGE_VERSION)) {
1614
1523
  if (external) throw new Error('source is already portable; select its complete bundle instead of legacy migration');
1615
1524
  return { source };
1616
1525
  }
@@ -1648,17 +1557,19 @@ export function createCaptainSessionStore(options = {}) {
1648
1557
  catch (cause) {
1649
1558
  if ((!sidecar && !external) || cause?.code !== 'ENOENT') throw cause;
1650
1559
  let receipt;
1651
- if (external) {
1560
+ if (external || sidecar) {
1652
1561
  receipt = JSON.parse(await readPrivateRegularFile(receiptPath, 0o600, fs, 'migration receipt'));
1653
- if (receipt.v !== 1 || receipt.id !== sessionId || !Array.isArray(receipt.inputs) || receipt.inputs[0]?.path !== sourcePath || ![1, 2].includes(receipt.inputs.length)) throw new Error('missing migration source has no matching receipt');
1654
- for (const [index, input] of receipt.inputs.entries()) {
1655
- const bytes = await readPrivateRegularFile(join(inputsDir, String(index)), 0o600, fs, 'retained migration input', true);
1656
- if (sha256(bytes) !== input.sha256) throw new Error('retained migration input differs');
1657
- }
1562
+ const [retainedSource] = await retainedMigrationInputs(receipt, sessionId, sourcePath, inputsDir);
1563
+ decodeSource(retainedSource.toString('utf8'));
1658
1564
  }
1659
1565
  const current = await validate(sessionId);
1660
1566
  if (!current.integrityValid) throw new Error('completed migration destination failed validation');
1661
- if (receipt?.complete === false) await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
1567
+ if (receipt?.complete === false) {
1568
+ const published = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'published migration root', true);
1569
+ if (!/^[0-9a-f]{64}$/.test(receipt.publishedRootSha256) || sha256(published) !== receipt.publishedRootSha256) throw new Error('completed migration root differs from its receipt');
1570
+ await lease.assertOwner();
1571
+ await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
1572
+ }
1662
1573
  return { manifest: current.manifest, migrated: false, reasons: current.reasons };
1663
1574
  }
1664
1575
  let { source, recovery, metadata } = decodeSource(sourceBytes);
@@ -1726,7 +1637,10 @@ export function createCaptainSessionStore(options = {}) {
1726
1637
  if (sidecar || external) {
1727
1638
  try {
1728
1639
  const existing = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'migration destination');
1729
- if (existing !== `${JSON.stringify(manifest)}\n`) throw new Error('migration destination manifest diverged');
1640
+ let decoded;
1641
+ try { decoded = await decodeStoredManifest(existing, chunksFor(sessionId), sessionId); }
1642
+ catch { throw new Error('migration destination manifest diverged'); }
1643
+ if (!isDeepStrictEqual(decoded, manifest)) throw new Error('migration destination manifest diverged');
1730
1644
  } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1731
1645
  }
1732
1646
  await ensurePrivateDirectory(inputsDir, fs, mayCreateDirectory);
@@ -1741,17 +1655,30 @@ export function createCaptainSessionStore(options = {}) {
1741
1655
  const receipt = { v: 1, id: sessionId, inputs: inputs.map(({ path, bytes }) => ({ path, sha256: sha256(bytes) })), complete: false };
1742
1656
  await writePrivateJson(receiptPath, receipt, fs);
1743
1657
  await lease.assertOwner();
1744
- await writePrivateBytes(recordsPathFor(sessionId), replayBytes, fs);
1745
- await writePrivateBytes(recordPathFor(sessionId), Buffer.from(`${JSON.stringify(manifest)}\n`), fs);
1658
+ const bounded = await createBoundedReplay({sessionId, sessionsDir, fsOps: fs, assertOwner: lease.assertOwner, chunks: chunksFor(sessionId)});
1659
+ const priorBounded = await bounded.readSnapshot();
1660
+ if (priorBounded.root === null) await bounded.initializeBytes(replayBytes);
1661
+ else if (!priorBounded.bytes.equals(replayBytes)) throw new Error('migration destination bounded replay diverged');
1662
+ const encoded = await encodeStoredManifest(manifest, chunksFor(sessionId));
1663
+ // A retry after publication sees schema 8 instead of its legacy source.
1664
+ // Bind that root before publishing so completion cannot retire unrelated
1665
+ // active legacy bytes merely because a new-format root exists.
1666
+ const publishedReceipt = { ...receipt, publishedRootSha256: sha256(encoded) };
1667
+ await writePrivateJson(receiptPath, publishedReceipt, fs);
1668
+ await lease.assertOwner();
1669
+ await writePrivateBytes(recordPathFor(sessionId), encoded, fs);
1746
1670
  const published = await validate(sessionId);
1747
1671
  if (!published.integrityValid) throw new Error('published migration bundle failed validation');
1672
+ // Original replay is retained in inputs before retiring its active name.
1673
+ try { await assertPrivateRegularPath(recordsPathFor(sessionId), 0o600, fs, 'legacy replay'); await fs.unlink(recordsPathFor(sessionId)); await syncDirectory(sessionsDir, fs); }
1674
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1748
1675
  if (external) {
1749
1676
  await sourceLease.assertOwner();
1750
1677
  try { await assertPrivateRegularPath(sourceReplayPath, 0o600, fs, 'migration source replay'); await fs.unlink(sourceReplayPath); await syncDirectory(sourceDir, fs); }
1751
1678
  catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1752
1679
  }
1753
1680
  if (sidecar || external) { await fs.unlink(sourcePath); await syncDirectory(sourceDir, fs); }
1754
- await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
1681
+ await writePrivateJson(receiptPath, { ...publishedReceipt, complete: true }, fs);
1755
1682
  return { manifest, migrated: true, reasons: [] };
1756
1683
  } catch (cause) {
1757
1684
  failure = { cause };
@@ -1800,9 +1727,20 @@ export function createCaptainSessionStore(options = {}) {
1800
1727
  const exportBundle = async (sessionId) => {
1801
1728
  assertSessionId(sessionId);
1802
1729
  for (let attempt = 0; attempt < 3; attempt++) {
1803
- const manifestText = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record');
1804
- const manifest = validateSessionManifest(JSON.parse(manifestText));
1805
- const replayBytes = Buffer.from(await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'replay stream'), 'utf8');
1730
+ let manifestText;
1731
+ try {
1732
+ manifestText = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record');
1733
+ const stored = JSON.parse(manifestText);
1734
+ const manifest = validateSessionManifest(await decodeStoredManifest(manifestText, chunksFor(sessionId), sessionId));
1735
+ let replayBytes, replayRoot, replayFiles = [];
1736
+ if (stored.schemaVersion === SESSION_STORAGE_VERSION) {
1737
+ const backend = await createBoundedReplay({sessionId, sessionsDir, fsOps: fs, chunks: chunksFor(sessionId)});
1738
+ const snapshot = await backend.readSnapshot();
1739
+ if (snapshot.root === null) throw new Error('session bundle replay root is missing');
1740
+ replayBytes = snapshot.bytes; replayRoot = snapshot.rootBytes;
1741
+ replayFiles = await backend.collectFiles();
1742
+ if (!(await backend.rootBytes())?.equals(replayRoot)) continue;
1743
+ } else replayBytes = await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'replay stream', true);
1806
1744
  let end = 0;
1807
1745
  for (let seq = 0; seq < manifest.replay.seq; seq++) {
1808
1746
  const next = replayBytes.indexOf(10, end);
@@ -1813,13 +1751,41 @@ export function createCaptainSessionStore(options = {}) {
1813
1751
  if (sha256(replay) !== manifest.replay.sha256 || manifest.replay.incomplete) throw new Error('session bundle replay checkpoint is incomplete or corrupt');
1814
1752
  assertAssetInventory(manifest, parseSessionHistory(replay).entries);
1815
1753
  const assets = [];
1754
+ const files = new Map([[`${sessionId}.json`, Buffer.byteLength(manifestText)]]);
1755
+ if (stored.schemaVersion === SESSION_STORAGE_VERSION) await chunksFor(sessionId).validateNamespace();
1756
+ if (stored.schemaVersion === SESSION_STORAGE_VERSION) {
1757
+ for (const entry of await chunksFor(sessionId).walk(stored.root)) files.set(`${sessionId}.chunks/${entry.path}`, entry.byteLength);
1758
+ for (const entry of replayFiles) files.set(entry.path, entry.byteLength);
1759
+ } else files.set(`${sessionId}.records.jsonl`, replayBytes.length);
1816
1760
  for (const reference of manifest.assets?.entries ?? []) {
1817
1761
  const reader = await assetStoreFor(sessionId).openAsset(reference); await reader.close();
1818
1762
  const hash = reference.assetId.slice(7);
1819
1763
  assets.push(Object.freeze({ assetId: reference.assetId, path: hash, metadataPath: `${hash}.json`, byteLength: reference.byteLength }));
1764
+ for (const name of [hash, `${hash}.json`]) {
1765
+ const path = `${sessionId}.assets/${name}`;
1766
+ files.set(path, (await assertPrivateRegularPath(join(sessionsDir, path), 0o600, fs, 'asset')).size);
1767
+ }
1820
1768
  }
1821
1769
  if (manifestText !== await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record')) continue;
1822
- return Object.freeze({ sessionId, manifest: Buffer.from(manifestText, 'utf8'), replay, assets: Object.freeze(assets) });
1770
+ if (replayRoot !== undefined) {
1771
+ const backend = await createBoundedReplay({sessionId, sessionsDir, fsOps: fs});
1772
+ if (!(await backend.rootBytes())?.equals(replayRoot)) continue;
1773
+ }
1774
+ return Object.freeze({ sessionId, manifest: Buffer.from(manifestText, 'utf8'), replay,
1775
+ assets: Object.freeze(assets), files: Object.freeze([...files].map(([path, byteLength]) => Object.freeze({path, byteLength}))) });
1776
+ } catch (cause) {
1777
+ if (cause?.privateFileChanged === true || cause?.replayRootRetry === true) continue;
1778
+ // Explicit migration may retire a legacy dependency after this
1779
+ // export pinned its old root. Retry only a proved root change;
1780
+ // missing or corrupt dependencies of an unchanged root still fail.
1781
+ if (manifestText !== undefined) {
1782
+ let current;
1783
+ try { current = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record'); }
1784
+ catch (changed) { if (changed?.privateFileChanged === true) continue; throw cause; }
1785
+ if (current !== manifestText) continue;
1786
+ }
1787
+ throw cause;
1788
+ }
1823
1789
  }
1824
1790
  throw new Error('session changed while exporting its bundle; retry');
1825
1791
  };
@@ -1854,6 +1820,7 @@ async function readReplayStream({
1854
1820
  afterSeq,
1855
1821
  cursor,
1856
1822
  fs,
1823
+ chunks,
1857
1824
  }) {
1858
1825
  let forceFullRead = false;
1859
1826
  let snapshot;
@@ -1865,7 +1832,7 @@ async function readReplayStream({
1865
1832
  afterSeq,
1866
1833
  cursor,
1867
1834
  forceFullRead,
1868
- fs,
1835
+ fs, chunks,
1869
1836
  });
1870
1837
  break;
1871
1838
  } catch (cause) {
@@ -1944,6 +1911,7 @@ async function readReplaySnapshot({
1944
1911
  cursor,
1945
1912
  forceFullRead,
1946
1913
  fs,
1914
+ chunks,
1947
1915
  }) {
1948
1916
  try {
1949
1917
  await assertPrivateDirectory(sessionsDir, fs);
@@ -1952,6 +1920,22 @@ async function readReplaySnapshot({
1952
1920
  throw cause;
1953
1921
  }
1954
1922
 
1923
+ // During explicit migration the old root remains authoritative until the
1924
+ // new manifest publishes. New replay-only sessions have no manifest yet.
1925
+ const sessionId = path.slice(path.lastIndexOf('/') + 1, -'.records.jsonl'.length);
1926
+ let storedRoot;
1927
+ try { storedRoot = JSON.parse(await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, fs, 'record')); }
1928
+ catch (cause) { if (cause?.code !== 'ENOENT' && !(cause instanceof SyntaxError)) throw cause; }
1929
+ if (storedRoot === undefined || ![2, 3, 4, 5, 6, 7].includes(storedRoot.schemaVersion)) {
1930
+ const backend = await createBoundedReplay({ sessionId, sessionsDir, fsOps: fs, chunks });
1931
+ const snapshot = await backend.readSnapshot();
1932
+ if (snapshot.root !== null) return {
1933
+ absent: false, bytes: snapshot.bytes, identity: {dev: 0, ino: 0},
1934
+ incremental: false, startOffset: 0,
1935
+ };
1936
+ if (storedRoot?.schemaVersion === SESSION_STORAGE_VERSION) return { absent: true };
1937
+ }
1938
+
1955
1939
  let pathStat;
1956
1940
  try {
1957
1941
  pathStat = await assertPrivateRegularPath(path, 0o600, fs, 'replay stream');
@@ -2070,268 +2054,6 @@ async function readReplayRange(handle, start, end) {
2070
2054
  return buffer;
2071
2055
  }
2072
2056
 
2073
- async function writeReplayRange(handle, bytes, start) {
2074
- let offset = 0;
2075
- while (offset < bytes.length) {
2076
- const result = await handle.write(
2077
- bytes,
2078
- offset,
2079
- bytes.length - offset,
2080
- start + offset,
2081
- );
2082
- if (
2083
- result === null ||
2084
- typeof result !== 'object' ||
2085
- !Number.isSafeInteger(result.bytesWritten) ||
2086
- result.bytesWritten <= 0 ||
2087
- result.bytesWritten > bytes.length - offset
2088
- ) {
2089
- throw new Error('replay stream append did not write its complete bytes');
2090
- }
2091
- offset += result.bytesWritten;
2092
- }
2093
- }
2094
-
2095
- async function readReplayWriterSnapshot({
2096
- sessionsDir,
2097
- path,
2098
- identity,
2099
- fs,
2100
- }) {
2101
- await assertPrivateDirectory(sessionsDir, fs);
2102
- let pathStat;
2103
- try {
2104
- pathStat = await assertPrivateRegularPath(path, 0o600, fs, 'replay stream');
2105
- } catch (cause) {
2106
- if (cause?.code === 'ENOENT' && identity === undefined) {
2107
- return { absent: true };
2108
- }
2109
- throw cause;
2110
- }
2111
- let handle;
2112
- try {
2113
- handle = await fs.open(
2114
- path,
2115
- constants.O_RDONLY |
2116
- (constants.O_NOFOLLOW ?? 0) |
2117
- (constants.O_NONBLOCK ?? 0),
2118
- );
2119
- const openedStat = await handle.stat();
2120
- assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
2121
- if (
2122
- !sameFileIdentity(pathStat, openedStat) ||
2123
- (identity !== undefined && !sameReplayIdentity(identity, openedStat))
2124
- ) {
2125
- throw new Error('replay stream identity changed for its writer');
2126
- }
2127
- const size = requireReplayFileSize(openedStat.size);
2128
- const bytes = await readReplayRange(handle, 0, size);
2129
- const finalHandleStat = await handle.stat();
2130
- assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream');
2131
- if (
2132
- !sameFileIdentity(openedStat, finalHandleStat) ||
2133
- requireReplayFileSize(finalHandleStat.size) !== size
2134
- ) {
2135
- throw new Error('replay stream changed during writer validation');
2136
- }
2137
- const finalPathStat = await assertPrivateRegularPath(
2138
- path,
2139
- 0o600,
2140
- fs,
2141
- 'replay stream',
2142
- );
2143
- if (!sameFileIdentity(openedStat, finalPathStat)) {
2144
- throw new Error('replay stream path changed during writer validation');
2145
- }
2146
- await assertPrivateDirectory(sessionsDir, fs);
2147
- return {
2148
- absent: false,
2149
- bytes,
2150
- identity: replayFileIdentity(openedStat),
2151
- };
2152
- } finally {
2153
- await handle?.close();
2154
- }
2155
- }
2156
-
2157
- async function mutateReplayWriterFile({
2158
- sessionsDir,
2159
- path,
2160
- identity,
2161
- expectedSize,
2162
- expectedFinalSize,
2163
- fs,
2164
- retainHandle = false,
2165
- operation,
2166
- }) {
2167
- await assertPrivateDirectory(sessionsDir, fs);
2168
- const pathStat = await assertPrivateRegularPath(
2169
- path,
2170
- 0o600,
2171
- fs,
2172
- 'replay stream',
2173
- );
2174
- if (!sameReplayIdentity(identity, pathStat)) {
2175
- throw new Error('replay stream identity changed for its writer');
2176
- }
2177
- let handle;
2178
- try {
2179
- handle = await fs.open(
2180
- path,
2181
- constants.O_RDWR |
2182
- (constants.O_NOFOLLOW ?? 0) |
2183
- (constants.O_NONBLOCK ?? 0),
2184
- );
2185
- const openedStat = await handle.stat();
2186
- assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
2187
- if (
2188
- !sameFileIdentity(pathStat, openedStat) ||
2189
- !sameReplayIdentity(identity, openedStat) ||
2190
- requireReplayFileSize(openedStat.size) !== expectedSize
2191
- ) {
2192
- throw new Error('replay stream changed before writer mutation');
2193
- }
2194
- await operation(handle);
2195
- const finalStat = await handle.stat();
2196
- assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
2197
- if (
2198
- !sameFileIdentity(openedStat, finalStat) ||
2199
- (expectedFinalSize !== undefined &&
2200
- requireReplayFileSize(finalStat.size) !== expectedFinalSize)
2201
- ) {
2202
- throw new Error('replay stream changed during writer mutation');
2203
- }
2204
- const finalPathStat = await assertPrivateRegularPath(
2205
- path,
2206
- 0o600,
2207
- fs,
2208
- 'replay stream',
2209
- );
2210
- if (!sameFileIdentity(openedStat, finalPathStat)) {
2211
- throw new Error('replay stream path changed during writer mutation');
2212
- }
2213
- await assertPrivateDirectory(sessionsDir, fs);
2214
- if (retainHandle) {
2215
- const retained = handle;
2216
- handle = undefined;
2217
- return retained;
2218
- }
2219
- } finally {
2220
- await handle?.close();
2221
- }
2222
- }
2223
-
2224
- async function appendReplayWriterFile({
2225
- handle,
2226
- identity,
2227
- expectedSize,
2228
- bytes,
2229
- }) {
2230
- const openedStat = await handle.stat();
2231
- assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
2232
- if (
2233
- !sameReplayIdentity(identity, openedStat) ||
2234
- requireReplayFileSize(openedStat.size) !== expectedSize
2235
- ) {
2236
- throw new Error('replay stream changed before writer append');
2237
- }
2238
- await writeReplayRange(handle, bytes, expectedSize);
2239
- }
2240
-
2241
- async function checkpointReplayWriterFile({
2242
- sessionsDir,
2243
- path,
2244
- handle,
2245
- identity,
2246
- expectedSize,
2247
- fs,
2248
- }) {
2249
- await assertPrivateDirectory(sessionsDir, fs);
2250
- const pathStat = await assertPrivateRegularPath(
2251
- path,
2252
- 0o600,
2253
- fs,
2254
- 'replay stream',
2255
- );
2256
- const openedStat = await handle.stat();
2257
- assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
2258
- if (
2259
- !sameFileIdentity(pathStat, openedStat) ||
2260
- !sameReplayIdentity(identity, openedStat) ||
2261
- requireReplayFileSize(openedStat.size) !== expectedSize
2262
- ) {
2263
- throw new Error('replay stream changed before writer checkpoint');
2264
- }
2265
- await handle.sync();
2266
- const finalStat = await handle.stat();
2267
- assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
2268
- if (
2269
- !sameFileIdentity(openedStat, finalStat) ||
2270
- requireReplayFileSize(finalStat.size) !== expectedSize
2271
- ) {
2272
- throw new Error('replay stream changed during writer checkpoint');
2273
- }
2274
- const finalPathStat = await assertPrivateRegularPath(
2275
- path,
2276
- 0o600,
2277
- fs,
2278
- 'replay stream',
2279
- );
2280
- if (!sameFileIdentity(openedStat, finalPathStat)) {
2281
- throw new Error('replay stream path changed during writer checkpoint');
2282
- }
2283
- await assertPrivateDirectory(sessionsDir, fs);
2284
- }
2285
-
2286
- async function publishReplayWriterFile({ sessionsDir, path, bytes, fs }) {
2287
- await assertPrivateDirectory(sessionsDir, fs);
2288
- let handle;
2289
- try {
2290
- handle = await fs.open(
2291
- path,
2292
- constants.O_RDWR |
2293
- constants.O_CREAT |
2294
- constants.O_EXCL |
2295
- (constants.O_NOFOLLOW ?? 0) |
2296
- (constants.O_NONBLOCK ?? 0),
2297
- 0o600,
2298
- );
2299
- await handle.chmod(0o600);
2300
- const openedStat = await handle.stat();
2301
- assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
2302
- if (requireReplayFileSize(openedStat.size) !== 0) {
2303
- throw new Error('new replay stream is not empty');
2304
- }
2305
- await writeReplayRange(handle, bytes, 0);
2306
- const finalStat = await handle.stat();
2307
- assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
2308
- if (
2309
- !sameFileIdentity(openedStat, finalStat) ||
2310
- requireReplayFileSize(finalStat.size) !== bytes.length
2311
- ) {
2312
- throw new Error('new replay stream did not retain its complete append');
2313
- }
2314
- const pathStat = await assertPrivateRegularPath(
2315
- path,
2316
- 0o600,
2317
- fs,
2318
- 'replay stream',
2319
- );
2320
- if (!sameFileIdentity(openedStat, pathStat)) {
2321
- throw new Error('new replay stream path changed during publication');
2322
- }
2323
- await assertPrivateDirectory(sessionsDir, fs);
2324
- const retained = handle;
2325
- handle = undefined;
2326
- return {
2327
- handle: retained,
2328
- identity: replayFileIdentity(openedStat),
2329
- };
2330
- } finally {
2331
- await handle?.close();
2332
- }
2333
- }
2334
-
2335
2057
  function parseReplayLines(bytes, initialSequence, initialDigest, observedSeq) {
2336
2058
  const entries = [];
2337
2059
  let sequence = initialSequence;
@@ -2682,6 +2404,7 @@ async function createLease({
2682
2404
  validateSession,
2683
2405
  portableWriters,
2684
2406
  assetStore,
2407
+ chunks,
2685
2408
  writeRecord,
2686
2409
  syncRecordDirectory,
2687
2410
  deleteRecord,
@@ -2734,6 +2457,7 @@ async function createLease({
2734
2457
  fs: replayFs,
2735
2458
  assertOwner: assertOwnerUnchecked,
2736
2459
  readStream: readReplayStream,
2460
+ chunks,
2737
2461
  });
2738
2462
 
2739
2463
  let contextSeq;
@@ -2789,14 +2513,14 @@ async function createLease({
2789
2513
  const readHints = async () => {
2790
2514
  try {
2791
2515
  const bytes = await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, replayFs, 'record');
2792
- const manifest = validateSessionManifest(JSON.parse(bytes));
2516
+ const manifest = validateSessionManifest(await decodeStoredManifest(bytes, chunks, sessionId));
2793
2517
  const hints = JSON.parse(await readPrivateRegularFile(hintsPath, 0o600, replayFs, 'hints'));
2794
2518
  return validateSessionHints(hints, bytes, manifest);
2795
2519
  } catch { return { players: {} }; }
2796
2520
  };
2797
2521
  const writeHints = async (hints) => {
2798
2522
  const bytes = await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, replayFs, 'record');
2799
- const manifest = validateSessionManifest(JSON.parse(bytes));
2523
+ const manifest = validateSessionManifest(await decodeStoredManifest(bytes, chunks, sessionId));
2800
2524
  const value = { v: 1, sessionId, checkpointSha256: sha256(bytes), players: hints.players, ...(hints.captain ? { captain: hints.captain } : {}) };
2801
2525
  validateSessionHints(value, bytes, manifest);
2802
2526
  await writePrivateJson(hintsPath, value, replayFs);
@@ -6673,6 +6397,10 @@ function nextTimestamp(value, previous) {
6673
6397
  return new Date(Date.parse(previous) + 1).toISOString();
6674
6398
  }
6675
6399
 
6400
+ function privateFileChanged(label, boundary) {
6401
+ return Object.assign(new Error(`${label} changed during ${boundary}`), { privateFileChanged: true });
6402
+ }
6403
+
6676
6404
  async function readPrivateRegularFile(path, mode, fs, label, raw = false) {
6677
6405
  const before = await assertPrivateRegularPath(path, mode, fs, label);
6678
6406
  const handle = await fs.open(
@@ -6687,11 +6415,11 @@ async function readPrivateRegularFile(path, mode, fs, label, raw = false) {
6687
6415
  if ((stat.mode & 0o7777) !== mode) {
6688
6416
  throw new Error(`${label} permissions must be ${octal(mode)}`);
6689
6417
  }
6690
- if (!sameFileIdentity(before, stat)) throw new Error(`${label} changed during open`);
6418
+ if (!sameFileIdentity(before, stat)) throw privateFileChanged(label, 'open');
6691
6419
  const bytes = await handle.readFile();
6692
6420
  const after = await handle.stat();
6693
6421
  const current = await assertPrivateRegularPath(path, mode, fs, label);
6694
- if (!sameFileIdentity(after, current) || after.size !== stat.size) throw new Error(`${label} changed during read`);
6422
+ if (!sameFileIdentity(after, current) || after.size !== stat.size) throw privateFileChanged(label, 'read');
6695
6423
  return raw ? bytes : new TextDecoder('utf-8', { fatal: true }).decode(bytes);
6696
6424
  } finally {
6697
6425
  await handle.close();
@@ -6833,7 +6561,7 @@ async function prepareSessionPermissions(sessionsDir, fs, selectedSessionId, inc
6833
6561
  const tighten = (path, before, directory) => tightenPrivateEntry(path, before, directory, fs, 'session permission preparation');
6834
6562
  await tighten(sessionsDir, initial, true);
6835
6563
  for (const name of await fs.readdir(sessionsDir)) {
6836
- const eligible = /^[0-9a-f-]{36}\.(?:json|records\.jsonl|hints\.json)$/.test(name)
6564
+ const eligible = /^[0-9a-f-]{36}\.(?:json|records\.jsonl|replay\.json|hints\.json)$/.test(name)
6837
6565
  || (includeSidecars && /^[0-9a-f-]{36}\.spex\.json$/.test(name));
6838
6566
  if (!eligible || (selectedSessionId !== undefined && !name.startsWith(`${selectedSessionId}.`))) continue;
6839
6567
  const path = join(sessionsDir, name);
@@ -6841,9 +6569,9 @@ async function prepareSessionPermissions(sessionsDir, fs, selectedSessionId, inc
6841
6569
  }
6842
6570
  }
6843
6571
 
6844
- async function readSessionHistory({ sessionsDir, path, fs, afterSeq = 0 }) {
6572
+ async function readSessionHistory({ sessionsDir, path, fs, chunks, afterSeq = 0 }) {
6845
6573
  if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) throw new Error('history afterSeq must be a nonnegative safe integer');
6846
- const snapshot = await readReplaySnapshot({ sessionsDir, path, fs, afterSeq: 0, forceFullRead: true });
6574
+ const snapshot = await readReplaySnapshot({ sessionsDir, path, fs, chunks, afterSeq: 0, forceFullRead: true });
6847
6575
  const bytes = snapshot.absent ? Buffer.alloc(0) : snapshot.bytes;
6848
6576
  return Object.freeze({ ...parseSessionHistory(bytes, afterSeq), missing: snapshot.absent === true });
6849
6577
  }