@sublang/playbook 17.11.1 → 18.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/cli.md +25 -15
- package/docs/embedding.md +12 -2
- package/package.json +6 -2
- package/reference/sdlc/code.md +8 -5
- package/reference/sdlc/code.playbook/bin/session-chunks.js +448 -0
- package/reference/sdlc/code.playbook/bin/session-replay.js +291 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +340 -612
- package/reference/sdlc/code.playbook/bin/storage-manifest.js +36 -0
- package/reference/sdlc/code.playbook/code.fsm.js +10 -7
- package/reference/sdlc/code.playbook/code.fsm.ts +10 -7
- package/reference/sdlc/code.playbook/code.gears.md +10 -7
- package/reference/sdlc/code.playbook/code.playbook.js +1 -1
- package/reference/sdlc/code.playbook/code.playbook.ts +1 -1
- package/reference/sdlc/code.playbook/session-store.d.ts +2 -0
|
@@ -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
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
assertOwner,
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 (
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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 &&
|
|
751
|
-
throw new CaptainSessionRecordNonresumableError(
|
|
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
|
|
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 =
|
|
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)
|
|
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
|
|
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(
|
|
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 ['.
|
|
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
|
-
|
|
1654
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
1745
|
-
await
|
|
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, { ...
|
|
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
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
}
|