@sublang/playbook 6.0.0 → 7.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.
- package/README.md +14 -10
- package/docs/cli.md +102 -57
- package/docs/configuration.md +57 -16
- package/package.json +3 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +938 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1171 -983
- package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +864 -68
- package/reference/sdlc/code.playbook/playbook-captain.ts +1296 -64
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +4 -14
- package/reference/sdlc/decide.playbook/decide.playbook.js +77 -13
- package/reference/sdlc/decide.playbook/decide.playbook.ts +93 -14
- package/slc/link.md +35 -12
- package/src/runtime.d.ts +14 -2
- package/src/runtime.ts +26 -6
- package/src/xstate-playbook-runtime.js +64 -14
- package/src/xstate-playbook-runtime.ts +79 -13
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +359 -57
- package/src/xstate-runtime.ts +491 -71
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
5
|
import PQueue from 'p-queue';
|
|
5
|
-
import { hiddenControlEnvelope, registerPlaybookAbortCleanup, } from '../../../src/xstate-runtime.js';
|
|
6
|
+
import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
|
|
6
7
|
import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
|
|
7
8
|
class VisibilityControlError extends Error {
|
|
8
9
|
constructor(cause) {
|
|
@@ -414,6 +415,267 @@ function isValidRegistryEntry(value) {
|
|
|
414
415
|
typeof e.validateOptions === 'function' &&
|
|
415
416
|
typeof e.createRuntime === 'function');
|
|
416
417
|
}
|
|
418
|
+
const SNAPSHOT_ACTIONS = new Set([
|
|
419
|
+
'respond',
|
|
420
|
+
'start',
|
|
421
|
+
'switch',
|
|
422
|
+
'dismiss',
|
|
423
|
+
'deliver',
|
|
424
|
+
'runtime',
|
|
425
|
+
]);
|
|
426
|
+
const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
|
|
427
|
+
'ok',
|
|
428
|
+
'rejected',
|
|
429
|
+
'failed',
|
|
430
|
+
]);
|
|
431
|
+
const SNAPSHOT_JOURNAL_KINDS = new Set([
|
|
432
|
+
'boss',
|
|
433
|
+
'reply',
|
|
434
|
+
'handoff',
|
|
435
|
+
'action',
|
|
436
|
+
'outcome',
|
|
437
|
+
]);
|
|
438
|
+
function snapshotRecord(value, path) {
|
|
439
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
440
|
+
throw new TypeError(`${path} must be an object`);
|
|
441
|
+
}
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
function rejectSnapshotKeys(value, allowed, path) {
|
|
445
|
+
const allowedKeys = new Set(allowed);
|
|
446
|
+
const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
447
|
+
if (unknown.length > 0) {
|
|
448
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function snapshotString(value, path, allowEmpty = false) {
|
|
452
|
+
if (typeof value !== 'string' ||
|
|
453
|
+
(!allowEmpty && value.trim().length === 0)) {
|
|
454
|
+
throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
|
|
455
|
+
}
|
|
456
|
+
return value;
|
|
457
|
+
}
|
|
458
|
+
function snapshotInteger(value, path, minimum = 0) {
|
|
459
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
460
|
+
throw new TypeError(`${path} must be an integer >= ${minimum}`);
|
|
461
|
+
}
|
|
462
|
+
return value;
|
|
463
|
+
}
|
|
464
|
+
function snapshotUuid(value, path) {
|
|
465
|
+
const id = snapshotString(value, path);
|
|
466
|
+
if (!UUID_PATTERN.test(id)) {
|
|
467
|
+
throw new TypeError(`${path} must be a UUID`);
|
|
468
|
+
}
|
|
469
|
+
return id;
|
|
470
|
+
}
|
|
471
|
+
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
472
|
+
function assertPlaybookCaptainShellSnapshot(value) {
|
|
473
|
+
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
474
|
+
const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
|
|
475
|
+
const mode = snapshot.mode;
|
|
476
|
+
const commonKeys = [
|
|
477
|
+
'schemaVersion',
|
|
478
|
+
'captain',
|
|
479
|
+
'issuedSessionIds',
|
|
480
|
+
'sequences',
|
|
481
|
+
'journal',
|
|
482
|
+
'lastAction',
|
|
483
|
+
'lastSettlementStatus',
|
|
484
|
+
'mode',
|
|
485
|
+
];
|
|
486
|
+
if (mode === 'chat') {
|
|
487
|
+
rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
|
|
488
|
+
}
|
|
489
|
+
else if (mode === 'engaged.parked') {
|
|
490
|
+
rejectSnapshotKeys(snapshot, [
|
|
491
|
+
...commonKeys,
|
|
492
|
+
'frames',
|
|
493
|
+
'rootPlayerResumeTokens',
|
|
494
|
+
'pendingBossQuestions',
|
|
495
|
+
'lastError',
|
|
496
|
+
], 'Captain shell snapshot');
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
|
|
500
|
+
}
|
|
501
|
+
if (snapshot.schemaVersion !== 1) {
|
|
502
|
+
throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`);
|
|
503
|
+
}
|
|
504
|
+
const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
|
|
505
|
+
rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'conversation'], 'Captain shell snapshot.captain');
|
|
506
|
+
const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
|
|
507
|
+
const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
|
|
508
|
+
const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
|
|
509
|
+
let normalizedConversation;
|
|
510
|
+
if (conversation.kind === 'pinned') {
|
|
511
|
+
rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
|
|
512
|
+
normalizedConversation = {
|
|
513
|
+
kind: 'pinned',
|
|
514
|
+
token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token', true),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
else if (conversation.kind === 'unopened' ||
|
|
518
|
+
conversation.kind === 'needsSeeding') {
|
|
519
|
+
rejectSnapshotKeys(conversation, ['kind'], 'Captain shell snapshot.captain.conversation');
|
|
520
|
+
normalizedConversation = { kind: conversation.kind };
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
throw new TypeError('Captain shell snapshot.captain.conversation.kind is not supported');
|
|
524
|
+
}
|
|
525
|
+
if (!Array.isArray(snapshot.issuedSessionIds)) {
|
|
526
|
+
throw new TypeError('Captain shell snapshot.issuedSessionIds must be an array');
|
|
527
|
+
}
|
|
528
|
+
const issued = snapshot.issuedSessionIds.map((id, index) => snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`));
|
|
529
|
+
if (new Set(issued).size !== issued.length) {
|
|
530
|
+
throw new TypeError('Captain shell snapshot.issuedSessionIds must not contain duplicates');
|
|
531
|
+
}
|
|
532
|
+
if (issued[0] !== captainSessionId) {
|
|
533
|
+
throw new TypeError('Captain shell snapshot Captain session id must be the first issued id');
|
|
534
|
+
}
|
|
535
|
+
const sequences = snapshotRecord(snapshot.sequences, 'Captain shell snapshot.sequences');
|
|
536
|
+
rejectSnapshotKeys(sequences, ['turn', 'journal'], 'Captain shell snapshot.sequences');
|
|
537
|
+
const turnSequence = snapshotInteger(sequences.turn, 'Captain shell snapshot.sequences.turn');
|
|
538
|
+
const journalSequence = snapshotInteger(sequences.journal, 'Captain shell snapshot.sequences.journal');
|
|
539
|
+
if (!Array.isArray(snapshot.journal)) {
|
|
540
|
+
throw new TypeError('Captain shell snapshot.journal must be an array');
|
|
541
|
+
}
|
|
542
|
+
const normalizedJournal = [];
|
|
543
|
+
let previousTurn = 0;
|
|
544
|
+
let bossRecords = 0;
|
|
545
|
+
for (const [index, value] of snapshot.journal.entries()) {
|
|
546
|
+
const record = snapshotRecord(value, `Captain shell snapshot.journal[${index}]`);
|
|
547
|
+
rejectSnapshotKeys(record, ['seq', 'turnId', 'kind', 'payload'], `Captain shell snapshot.journal[${index}]`);
|
|
548
|
+
const seq = snapshotInteger(record.seq, `Captain shell snapshot.journal[${index}].seq`, 1);
|
|
549
|
+
if (seq !== index + 1) {
|
|
550
|
+
throw new TypeError('Captain shell snapshot journal sequence must be contiguous from one');
|
|
551
|
+
}
|
|
552
|
+
const turnId = snapshotInteger(record.turnId, `Captain shell snapshot.journal[${index}].turnId`, 1);
|
|
553
|
+
if (turnId < previousTurn || turnId > turnSequence) {
|
|
554
|
+
throw new TypeError('Captain shell snapshot journal turn ids must be ordered and in range');
|
|
555
|
+
}
|
|
556
|
+
const kind = record.kind;
|
|
557
|
+
if (typeof kind !== 'string' ||
|
|
558
|
+
!SNAPSHOT_JOURNAL_KINDS.has(kind)) {
|
|
559
|
+
throw new TypeError(`Captain shell snapshot.journal[${index}].kind is not supported`);
|
|
560
|
+
}
|
|
561
|
+
if (turnId !== previousTurn) {
|
|
562
|
+
if (turnId !== previousTurn + 1 || kind !== 'boss') {
|
|
563
|
+
throw new TypeError('Captain shell snapshot journal must begin every turn with one boss record');
|
|
564
|
+
}
|
|
565
|
+
bossRecords++;
|
|
566
|
+
previousTurn = turnId;
|
|
567
|
+
}
|
|
568
|
+
else if (kind === 'boss') {
|
|
569
|
+
throw new TypeError('Captain shell snapshot journal must contain one boss record per turn');
|
|
570
|
+
}
|
|
571
|
+
normalizedJournal.push({
|
|
572
|
+
seq,
|
|
573
|
+
turnId,
|
|
574
|
+
kind: kind,
|
|
575
|
+
payload: record.payload,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
if (journalSequence !== normalizedJournal.length ||
|
|
579
|
+
bossRecords !== turnSequence) {
|
|
580
|
+
throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
|
|
581
|
+
}
|
|
582
|
+
let lastAction;
|
|
583
|
+
if (snapshot.lastAction !== undefined) {
|
|
584
|
+
if (typeof snapshot.lastAction !== 'string' ||
|
|
585
|
+
!SNAPSHOT_ACTIONS.has(snapshot.lastAction)) {
|
|
586
|
+
throw new TypeError('Captain shell snapshot.lastAction is not supported');
|
|
587
|
+
}
|
|
588
|
+
lastAction = snapshot.lastAction;
|
|
589
|
+
}
|
|
590
|
+
let lastSettlementStatus;
|
|
591
|
+
if (snapshot.lastSettlementStatus !== undefined) {
|
|
592
|
+
if (typeof snapshot.lastSettlementStatus !== 'string' ||
|
|
593
|
+
!SNAPSHOT_SETTLEMENT_STATUSES.has(snapshot.lastSettlementStatus)) {
|
|
594
|
+
throw new TypeError('Captain shell snapshot.lastSettlementStatus is not supported');
|
|
595
|
+
}
|
|
596
|
+
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
597
|
+
}
|
|
598
|
+
const common = {
|
|
599
|
+
schemaVersion: 1,
|
|
600
|
+
captain: {
|
|
601
|
+
sessionId: captainSessionId,
|
|
602
|
+
runtime: captainRuntime,
|
|
603
|
+
conversation: normalizedConversation,
|
|
604
|
+
},
|
|
605
|
+
issuedSessionIds: issued,
|
|
606
|
+
sequences: { turn: turnSequence, journal: journalSequence },
|
|
607
|
+
journal: normalizedJournal,
|
|
608
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
609
|
+
...(lastSettlementStatus === undefined
|
|
610
|
+
? {}
|
|
611
|
+
: { lastSettlementStatus }),
|
|
612
|
+
};
|
|
613
|
+
if (mode === 'chat') {
|
|
614
|
+
return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
|
|
615
|
+
}
|
|
616
|
+
if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
|
|
617
|
+
throw new TypeError('Captain shell snapshot.frames must be a non-empty array');
|
|
618
|
+
}
|
|
619
|
+
const normalizedFrames = [];
|
|
620
|
+
for (const [index, value] of snapshot.frames.entries()) {
|
|
621
|
+
const frame = snapshotRecord(value, `Captain shell snapshot.frames[${index}]`);
|
|
622
|
+
rejectSnapshotKeys(frame, [
|
|
623
|
+
'playbookId',
|
|
624
|
+
'sessionId',
|
|
625
|
+
'rootSessionId',
|
|
626
|
+
'depth',
|
|
627
|
+
'parentSessionId',
|
|
628
|
+
'parentCallId',
|
|
629
|
+
'runtime',
|
|
630
|
+
], `Captain shell snapshot.frames[${index}]`);
|
|
631
|
+
const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
|
|
632
|
+
const sessionId = snapshotUuid(frame.sessionId, `Captain shell snapshot.frames[${index}].sessionId`);
|
|
633
|
+
const rootSessionId = snapshotUuid(frame.rootSessionId, `Captain shell snapshot.frames[${index}].rootSessionId`);
|
|
634
|
+
const depth = snapshotInteger(frame.depth, `Captain shell snapshot.frames[${index}].depth`);
|
|
635
|
+
const parentSessionId = frame.parentSessionId === undefined
|
|
636
|
+
? undefined
|
|
637
|
+
: snapshotUuid(frame.parentSessionId, `Captain shell snapshot.frames[${index}].parentSessionId`);
|
|
638
|
+
const parentCallId = frame.parentCallId === undefined
|
|
639
|
+
? undefined
|
|
640
|
+
: snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
|
|
641
|
+
const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
|
|
642
|
+
normalizedFrames.push({
|
|
643
|
+
playbookId,
|
|
644
|
+
sessionId,
|
|
645
|
+
rootSessionId,
|
|
646
|
+
depth,
|
|
647
|
+
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
648
|
+
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
649
|
+
runtime,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
const rootTokens = snapshotRecord(snapshot.rootPlayerResumeTokens, 'Captain shell snapshot.rootPlayerResumeTokens');
|
|
653
|
+
const normalizedRootTokens = Object.fromEntries(Object.entries(rootTokens).map(([playerId, token]) => [
|
|
654
|
+
playerId,
|
|
655
|
+
snapshotString(token, `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`),
|
|
656
|
+
]));
|
|
657
|
+
let normalizedLastError;
|
|
658
|
+
if (snapshot.lastError !== undefined) {
|
|
659
|
+
const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
|
|
660
|
+
rejectSnapshotKeys(error, ['name', 'message'], 'Captain shell snapshot.lastError');
|
|
661
|
+
normalizedLastError = {
|
|
662
|
+
name: snapshotString(error.name, 'Captain shell snapshot.lastError.name', true),
|
|
663
|
+
message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return snapshotJsonValue({
|
|
667
|
+
...common,
|
|
668
|
+
mode,
|
|
669
|
+
frames: normalizedFrames,
|
|
670
|
+
rootPlayerResumeTokens: normalizedRootTokens,
|
|
671
|
+
...(snapshot.pendingBossQuestions === undefined
|
|
672
|
+
? {}
|
|
673
|
+
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
674
|
+
...(normalizedLastError === undefined
|
|
675
|
+
? {}
|
|
676
|
+
: { lastError: normalizedLastError }),
|
|
677
|
+
}, 'Captain shell snapshot');
|
|
678
|
+
}
|
|
417
679
|
function readPlaybooksConfig(options) {
|
|
418
680
|
if (typeof options !== 'object' || options === null)
|
|
419
681
|
return undefined;
|
|
@@ -512,6 +774,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
512
774
|
let byId = new Map();
|
|
513
775
|
let enablementById = new Map();
|
|
514
776
|
let session;
|
|
777
|
+
let sessionEmissionsOpen = false;
|
|
778
|
+
let closedGateAttempted = false;
|
|
779
|
+
let lifecycle = 'fresh';
|
|
780
|
+
let terminallyDisposed = false;
|
|
515
781
|
let players = [];
|
|
516
782
|
let activeContext;
|
|
517
783
|
const frames = [];
|
|
@@ -524,6 +790,41 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
524
790
|
const pendingChildParents = new Set();
|
|
525
791
|
const captainQueue = new PQueue({ concurrency: 1 });
|
|
526
792
|
let disposing = false;
|
|
793
|
+
const admitHostBoundary = () => {
|
|
794
|
+
if (sessionEmissionsOpen)
|
|
795
|
+
return;
|
|
796
|
+
closedGateAttempted = true;
|
|
797
|
+
throw new Error('Captain shell host boundaries are closed during restore');
|
|
798
|
+
};
|
|
799
|
+
const admitHostEmission = () => {
|
|
800
|
+
if (sessionEmissionsOpen)
|
|
801
|
+
return true;
|
|
802
|
+
closedGateAttempted = true;
|
|
803
|
+
return false;
|
|
804
|
+
};
|
|
805
|
+
const installSession = (initSession, emissionsOpen) => {
|
|
806
|
+
sessionEmissionsOpen = emissionsOpen;
|
|
807
|
+
closedGateAttempted = false;
|
|
808
|
+
session = {
|
|
809
|
+
signal: initSession.signal,
|
|
810
|
+
players: initSession.players,
|
|
811
|
+
emitStatus: async (message, data) => {
|
|
812
|
+
if (!admitHostEmission())
|
|
813
|
+
return;
|
|
814
|
+
await initSession.emitStatus(message, data);
|
|
815
|
+
},
|
|
816
|
+
emitTelemetry: async (event) => {
|
|
817
|
+
if (!admitHostEmission())
|
|
818
|
+
return;
|
|
819
|
+
await initSession.emitTelemetry(event);
|
|
820
|
+
},
|
|
821
|
+
setVisiblePlayers: async (playerIds) => {
|
|
822
|
+
if (!admitHostEmission())
|
|
823
|
+
return;
|
|
824
|
+
await initSession.setVisiblePlayers(playerIds);
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
};
|
|
527
828
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
528
829
|
let captainRuntime;
|
|
529
830
|
let captainSessionId;
|
|
@@ -760,6 +1061,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
760
1061
|
let callNestedPlaybook;
|
|
761
1062
|
const createPorts = (frame) => ({
|
|
762
1063
|
callPlayer: async (playerId, prompt, signal, options) => {
|
|
1064
|
+
admitHostBoundary();
|
|
763
1065
|
if (!activeContext) {
|
|
764
1066
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
765
1067
|
}
|
|
@@ -793,6 +1095,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
793
1095
|
};
|
|
794
1096
|
},
|
|
795
1097
|
callCaptain: async (prompt, signal, options) => {
|
|
1098
|
+
admitHostBoundary();
|
|
796
1099
|
if (!activeContext) {
|
|
797
1100
|
throw new Error('callCaptain invoked outside a Boss turn');
|
|
798
1101
|
}
|
|
@@ -810,6 +1113,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
810
1113
|
};
|
|
811
1114
|
},
|
|
812
1115
|
callJudge: async (prompt, signal) => {
|
|
1116
|
+
admitHostBoundary();
|
|
813
1117
|
if (!activeContext) {
|
|
814
1118
|
throw new Error('callJudge invoked outside a Boss turn');
|
|
815
1119
|
}
|
|
@@ -835,6 +1139,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
835
1139
|
return result.finalText;
|
|
836
1140
|
},
|
|
837
1141
|
callPlaybook: (request, signal) => {
|
|
1142
|
+
admitHostBoundary();
|
|
838
1143
|
const opening = callNestedPlaybook(frame, request, signal);
|
|
839
1144
|
let exposed;
|
|
840
1145
|
const registerOpeningCleanup = () => {
|
|
@@ -848,14 +1153,23 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
848
1153
|
registerOpeningCleanup();
|
|
849
1154
|
return exposed;
|
|
850
1155
|
},
|
|
851
|
-
emitStatus:
|
|
852
|
-
|
|
1156
|
+
emitStatus: (message, data) => {
|
|
1157
|
+
if (!admitHostEmission())
|
|
1158
|
+
return Promise.resolve();
|
|
1159
|
+
return trackHostCall(frame, (async () => {
|
|
1160
|
+
await requireSession().emitStatus(message, data);
|
|
1161
|
+
})());
|
|
853
1162
|
},
|
|
854
|
-
emitTelemetry:
|
|
855
|
-
if (
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1163
|
+
emitTelemetry: (event) => {
|
|
1164
|
+
if (!admitHostEmission())
|
|
1165
|
+
return Promise.resolve();
|
|
1166
|
+
const emission = (async () => {
|
|
1167
|
+
if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
|
|
1168
|
+
await mirrorSubRuntimeTelemetry(frame, event.payload);
|
|
1169
|
+
}
|
|
1170
|
+
await requireSession().emitTelemetry(event);
|
|
1171
|
+
})();
|
|
1172
|
+
return trackHostCall(frame, emission);
|
|
859
1173
|
},
|
|
860
1174
|
});
|
|
861
1175
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
@@ -896,9 +1210,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
896
1210
|
: undefined;
|
|
897
1211
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
898
1212
|
};
|
|
899
|
-
const
|
|
1213
|
+
const makePlayerBindings = (enablement, parent) => {
|
|
900
1214
|
const entry = enablement.entry;
|
|
901
|
-
const sessionId = allocateSessionId();
|
|
902
1215
|
const playerBindings = new Map();
|
|
903
1216
|
for (const role of entry.requiredRoleIds) {
|
|
904
1217
|
let inherited;
|
|
@@ -915,6 +1228,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
915
1228
|
player: configured,
|
|
916
1229
|
});
|
|
917
1230
|
}
|
|
1231
|
+
return playerBindings;
|
|
1232
|
+
};
|
|
1233
|
+
const makeFrame = (enablement, parent) => {
|
|
1234
|
+
const entry = enablement.entry;
|
|
1235
|
+
const sessionId = allocateSessionId();
|
|
1236
|
+
const playerBindings = makePlayerBindings(enablement, parent);
|
|
918
1237
|
const playerResumeTokens = parent?.frame.playerResumeTokens ?? new Map();
|
|
919
1238
|
const runtime = entry.createRuntime({
|
|
920
1239
|
captainOptions: enablement.optionInput,
|
|
@@ -937,6 +1256,31 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
937
1256
|
inFlightHostCalls: new Set(),
|
|
938
1257
|
};
|
|
939
1258
|
};
|
|
1259
|
+
const makeRestoredFrame = (enablement, snapshot, rootPlayerResumeTokens, parent) => {
|
|
1260
|
+
const entry = enablement.entry;
|
|
1261
|
+
const playerBindings = makePlayerBindings(enablement, parent);
|
|
1262
|
+
const runtime = entry.createRuntime({
|
|
1263
|
+
captainOptions: enablement.optionInput,
|
|
1264
|
+
players: [...playerBindings].map(([role, { player }]) => ({
|
|
1265
|
+
id: role,
|
|
1266
|
+
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
1267
|
+
...(player.model === undefined ? {} : { model: player.model }),
|
|
1268
|
+
})),
|
|
1269
|
+
});
|
|
1270
|
+
return {
|
|
1271
|
+
entry,
|
|
1272
|
+
enablement,
|
|
1273
|
+
runtime,
|
|
1274
|
+
sessionId: snapshot.sessionId,
|
|
1275
|
+
rootSessionId: snapshot.rootSessionId,
|
|
1276
|
+
depth: snapshot.depth,
|
|
1277
|
+
playerBindings,
|
|
1278
|
+
playerResumeTokens: rootPlayerResumeTokens,
|
|
1279
|
+
...(parent ? { parent } : {}),
|
|
1280
|
+
state: snapshot.runtime.state,
|
|
1281
|
+
inFlightHostCalls: new Set(),
|
|
1282
|
+
};
|
|
1283
|
+
};
|
|
940
1284
|
const playerSessionStore = (frame) => ({
|
|
941
1285
|
select(playerId) {
|
|
942
1286
|
const binding = bindingFor(frame, playerId);
|
|
@@ -970,21 +1314,22 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
970
1314
|
}
|
|
971
1315
|
},
|
|
972
1316
|
});
|
|
1317
|
+
const frameSession = (frame) => ({
|
|
1318
|
+
sessionId: frame.sessionId,
|
|
1319
|
+
playbookId: frame.entry.id,
|
|
1320
|
+
rootSessionId: frame.rootSessionId,
|
|
1321
|
+
...(frame.parent
|
|
1322
|
+
? {
|
|
1323
|
+
parentSessionId: frame.parent.frame.sessionId,
|
|
1324
|
+
parentCallId: frame.parent.callId,
|
|
1325
|
+
}
|
|
1326
|
+
: {}),
|
|
1327
|
+
depth: frame.depth,
|
|
1328
|
+
playerSessions: playerSessionStore(frame),
|
|
1329
|
+
ports: createPorts(frame),
|
|
1330
|
+
});
|
|
973
1331
|
const initFrame = async (frame) => {
|
|
974
|
-
await frame.runtime.init(
|
|
975
|
-
sessionId: frame.sessionId,
|
|
976
|
-
playbookId: frame.entry.id,
|
|
977
|
-
rootSessionId: frame.rootSessionId,
|
|
978
|
-
...(frame.parent
|
|
979
|
-
? {
|
|
980
|
-
parentSessionId: frame.parent.frame.sessionId,
|
|
981
|
-
parentCallId: frame.parent.callId,
|
|
982
|
-
}
|
|
983
|
-
: {}),
|
|
984
|
-
depth: frame.depth,
|
|
985
|
-
playerSessions: playerSessionStore(frame),
|
|
986
|
-
ports: createPorts(frame),
|
|
987
|
-
});
|
|
1332
|
+
await frame.runtime.init(frameSession(frame));
|
|
988
1333
|
};
|
|
989
1334
|
const clearLeafLedger = () => {
|
|
990
1335
|
pendingBossQuestions = undefined;
|
|
@@ -1346,6 +1691,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1346
1691
|
await resumeParent(frame, callResultFor(frame, result), context);
|
|
1347
1692
|
}
|
|
1348
1693
|
else {
|
|
1694
|
+
// CAPTAIN-20: the root is still alive here, so this is the one
|
|
1695
|
+
// authoritative boundary that can retain the Boss-facing meaning its
|
|
1696
|
+
// runtime publishes before disposal removes the frame. The opaque run
|
|
1697
|
+
// output remains runtime-to-runtime data and never becomes Captain
|
|
1698
|
+
// evidence (CAPPLAY-10).
|
|
1699
|
+
activeTurn?.settlementFacts.push(rootCompletionFact(frame));
|
|
1349
1700
|
await runEffect(() => disposeStack('final'));
|
|
1350
1701
|
}
|
|
1351
1702
|
return;
|
|
@@ -2025,9 +2376,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2025
2376
|
// -------------------------------------------------------------------------
|
|
2026
2377
|
const captainPorts = () => ({
|
|
2027
2378
|
callPlayer: async () => {
|
|
2379
|
+
admitHostBoundary();
|
|
2028
2380
|
throw new Error('the session Captain has no players');
|
|
2029
2381
|
},
|
|
2030
2382
|
callCaptain: async (prompt, signal) => {
|
|
2383
|
+
admitHostBoundary();
|
|
2031
2384
|
if (!activeContext) {
|
|
2032
2385
|
throw new Error('the session Captain called out of a Boss turn');
|
|
2033
2386
|
}
|
|
@@ -2085,29 +2438,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2085
2438
|
return { status: 'ok', finalText: 'ok' };
|
|
2086
2439
|
},
|
|
2087
2440
|
callJudge: async () => {
|
|
2441
|
+
admitHostBoundary();
|
|
2088
2442
|
throw new Error('the session Captain makes no judge call');
|
|
2089
2443
|
},
|
|
2090
2444
|
callPlaybook: async () => {
|
|
2445
|
+
admitHostBoundary();
|
|
2091
2446
|
throw new Error('the session Captain never calls a playbook');
|
|
2092
2447
|
},
|
|
2093
2448
|
// CAPTAIN-9: the session Captain's human status stream is suppressed while
|
|
2094
2449
|
// its structured telemetry is forwarded.
|
|
2095
|
-
emitStatus: async () => {
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2450
|
+
emitStatus: async () => {
|
|
2451
|
+
admitHostEmission();
|
|
2452
|
+
},
|
|
2453
|
+
emitTelemetry: (event) => {
|
|
2454
|
+
if (!admitHostEmission())
|
|
2455
|
+
return Promise.resolve();
|
|
2456
|
+
const emission = (async () => {
|
|
2457
|
+
if (event.topic === 'playbook.trace') {
|
|
2458
|
+
const payload = payloadRecord(event.payload);
|
|
2459
|
+
if (payload?.type === 'captain.call.started') {
|
|
2460
|
+
const identity = payloadRecord(payload.payload);
|
|
2461
|
+
const stateId = identity?.stateId;
|
|
2462
|
+
servingCall =
|
|
2463
|
+
stateId === 'reporting'
|
|
2464
|
+
? 'closingReply'
|
|
2465
|
+
: stateId === 'answeringCommand'
|
|
2466
|
+
? 'commandReply'
|
|
2467
|
+
: 'decision';
|
|
2468
|
+
}
|
|
2108
2469
|
}
|
|
2109
|
-
|
|
2110
|
-
|
|
2470
|
+
await requireSession().emitTelemetry(event);
|
|
2471
|
+
})();
|
|
2472
|
+
return trackTurnCall(emission);
|
|
2111
2473
|
},
|
|
2112
2474
|
});
|
|
2113
2475
|
const resolveCommandTurn = (text) => {
|
|
@@ -2175,6 +2537,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2175
2537
|
return undefined;
|
|
2176
2538
|
}
|
|
2177
2539
|
};
|
|
2540
|
+
const rootCompletionFact = (frame) => {
|
|
2541
|
+
const published = leafStateDescription(frame);
|
|
2542
|
+
const description = published === undefined ? '' : compactEvidence(published);
|
|
2543
|
+
return description === ''
|
|
2544
|
+
? `${frameLabel(frame)} completed; its runtime published no result description.`
|
|
2545
|
+
: `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
|
|
2546
|
+
};
|
|
2178
2547
|
const leafStateSummary = () => {
|
|
2179
2548
|
const leaf = leafFrame();
|
|
2180
2549
|
if (!leaf)
|
|
@@ -2573,9 +2942,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2573
2942
|
};
|
|
2574
2943
|
throw outcome.error;
|
|
2575
2944
|
}
|
|
2576
|
-
if (!frames.includes(leaf)) {
|
|
2577
|
-
facts.push(`${frameLabel(leaf)} finished and was disposed.`);
|
|
2578
|
-
}
|
|
2579
2945
|
const runFailed = drainRunFailureFacts(facts);
|
|
2580
2946
|
const summary = leafStateSummary();
|
|
2581
2947
|
turn.report = {
|
|
@@ -2730,8 +3096,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2730
3096
|
};
|
|
2731
3097
|
};
|
|
2732
3098
|
const controller = {
|
|
2733
|
-
submit: (selection, signal) =>
|
|
2734
|
-
|
|
3099
|
+
submit: (selection, signal) => {
|
|
3100
|
+
admitHostBoundary();
|
|
3101
|
+
return settleSelection(selection, signal);
|
|
3102
|
+
},
|
|
3103
|
+
resolveParsedTurn: () => {
|
|
3104
|
+
admitHostBoundary();
|
|
3105
|
+
return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
|
|
3106
|
+
},
|
|
2735
3107
|
};
|
|
2736
3108
|
// -------------------------------------------------------------------------
|
|
2737
3109
|
// Failure surface (CAPTAIN-34): a Boss-appropriate reply naming a concrete
|
|
@@ -2800,40 +3172,455 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2800
3172
|
text: failureReplyText(),
|
|
2801
3173
|
});
|
|
2802
3174
|
};
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
3175
|
+
const enabledCatalog = () => Object.freeze(entries.map((entry) => Object.freeze({
|
|
3176
|
+
id: entry.id,
|
|
3177
|
+
command: enablementById.get(entry.id).command,
|
|
3178
|
+
intent: entry.intent,
|
|
3179
|
+
})));
|
|
3180
|
+
const captainPlaybookSession = (id) => ({
|
|
3181
|
+
sessionId: id,
|
|
3182
|
+
playbookId: INTERNAL_CAPTAIN_ID,
|
|
3183
|
+
rootSessionId: id,
|
|
3184
|
+
depth: 0,
|
|
3185
|
+
ports: captainPorts(),
|
|
3186
|
+
});
|
|
3187
|
+
const tokenRecord = (tokens) => Object.fromEntries(tokens);
|
|
3188
|
+
const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
|
|
3189
|
+
const captain = snapshot.captain.runtime;
|
|
3190
|
+
if (captain.schemaVersion !== 2 ||
|
|
3191
|
+
captain.state.status !== 'active' ||
|
|
3192
|
+
!captain.state.quiescent ||
|
|
3193
|
+
!captain.state.tags.includes('playbook.parked') ||
|
|
3194
|
+
captain.suspendedCall !== undefined ||
|
|
3195
|
+
Object.keys(captain.playerResumeTokens).length > 0 ||
|
|
3196
|
+
captain.pendingBossQuestions.length > 0) {
|
|
3197
|
+
throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
|
|
3198
|
+
}
|
|
3199
|
+
if (captain.sequences.turn !== snapshot.sequences.turn) {
|
|
3200
|
+
throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
|
|
3201
|
+
}
|
|
3202
|
+
const emptyHistory = snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
|
|
3203
|
+
if ((snapshot.captain.conversation.kind === 'unopened') !== emptyHistory) {
|
|
3204
|
+
throw new TypeError('Captain shell snapshot unopened conversation must exactly match an empty session history');
|
|
3205
|
+
}
|
|
3206
|
+
if (snapshot.mode === 'chat')
|
|
3207
|
+
return;
|
|
3208
|
+
const activePlaybooks = new Set();
|
|
3209
|
+
const activeSessionIds = new Set([snapshot.captain.sessionId]);
|
|
3210
|
+
const issuedIds = new Set(snapshot.issuedSessionIds);
|
|
3211
|
+
const allowedHostPlayerIds = new Set();
|
|
3212
|
+
for (const enablement of enabled.values()) {
|
|
3213
|
+
for (const role of enablement.entry.requiredRoleIds) {
|
|
3214
|
+
allowedHostPlayerIds.add(enablement.hostPlayerId(role));
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
|
|
3218
|
+
if (!allowedHostPlayerIds.has(playerId)) {
|
|
3219
|
+
throw new TypeError(`Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
const bindingMaps = [];
|
|
3223
|
+
const rootSessionId = snapshot.frames[0].sessionId;
|
|
3224
|
+
for (const [index, frame] of snapshot.frames.entries()) {
|
|
3225
|
+
const enablement = enabled.get(frame.playbookId);
|
|
3226
|
+
if (!enablement) {
|
|
3227
|
+
throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
|
|
3228
|
+
}
|
|
3229
|
+
if (activePlaybooks.has(frame.playbookId)) {
|
|
3230
|
+
throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
|
|
3231
|
+
}
|
|
3232
|
+
activePlaybooks.add(frame.playbookId);
|
|
3233
|
+
if (activeSessionIds.has(frame.sessionId)) {
|
|
3234
|
+
throw new TypeError('Captain shell snapshot frame session ids must be unique');
|
|
3235
|
+
}
|
|
3236
|
+
activeSessionIds.add(frame.sessionId);
|
|
3237
|
+
if (!issuedIds.has(frame.sessionId)) {
|
|
3238
|
+
throw new TypeError('Captain shell snapshot frame session id was not historically issued');
|
|
3239
|
+
}
|
|
3240
|
+
if (frame.depth !== index ||
|
|
3241
|
+
frame.rootSessionId !== rootSessionId ||
|
|
3242
|
+
frame.runtime.state.status !== 'active' ||
|
|
3243
|
+
!frame.runtime.state.quiescent) {
|
|
3244
|
+
throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
|
|
3245
|
+
}
|
|
3246
|
+
if (index === 0) {
|
|
3247
|
+
if (frame.sessionId !== frame.rootSessionId ||
|
|
3248
|
+
frame.parentSessionId !== undefined ||
|
|
3249
|
+
frame.parentCallId !== undefined) {
|
|
3250
|
+
throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
else {
|
|
3254
|
+
const parent = snapshot.frames[index - 1];
|
|
3255
|
+
if (frame.parentSessionId !== parent.sessionId ||
|
|
3256
|
+
frame.parentCallId === undefined) {
|
|
3257
|
+
throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
|
|
3258
|
+
}
|
|
3259
|
+
const pending = parent.runtime.suspendedCall;
|
|
3260
|
+
if (!pending ||
|
|
3261
|
+
pending.callId !== frame.parentCallId ||
|
|
3262
|
+
pending.playbookId !== frame.playbookId ||
|
|
3263
|
+
pending.childSessionId !== frame.sessionId) {
|
|
3264
|
+
throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
const roleBindings = new Map();
|
|
3268
|
+
for (const role of enablement.entry.requiredRoleIds) {
|
|
3269
|
+
let inherited;
|
|
3270
|
+
for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
|
|
3271
|
+
inherited = bindingMaps[ancestor]?.get(role);
|
|
3272
|
+
if (inherited !== undefined)
|
|
3273
|
+
break;
|
|
3274
|
+
}
|
|
3275
|
+
roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
|
|
3276
|
+
}
|
|
3277
|
+
bindingMaps.push(roleBindings);
|
|
3278
|
+
const projectedTokens = Object.fromEntries([...roleBindings].flatMap(([role, hostPlayerId]) => {
|
|
3279
|
+
const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
|
|
3280
|
+
return token === undefined ? [] : [[role, token]];
|
|
3281
|
+
}));
|
|
3282
|
+
if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
|
|
3283
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`);
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
const leafRuntime = snapshot.frames.at(-1).runtime;
|
|
3287
|
+
if (leafRuntime.suspendedCall !== undefined ||
|
|
3288
|
+
!leafRuntime.state.tags.includes('playbook.parked')) {
|
|
3289
|
+
throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
|
|
3290
|
+
}
|
|
3291
|
+
};
|
|
3292
|
+
const safeCapturePoint = () => {
|
|
3293
|
+
if (lifecycle !== 'ready' ||
|
|
3294
|
+
terminallyDisposed ||
|
|
3295
|
+
!sessionEmissionsOpen ||
|
|
3296
|
+
!session ||
|
|
3297
|
+
session.signal.aborted ||
|
|
3298
|
+
!captainRuntime ||
|
|
3299
|
+
disposing ||
|
|
3300
|
+
shuttingDown ||
|
|
3301
|
+
activeContext !== undefined ||
|
|
3302
|
+
activeTurn !== undefined ||
|
|
3303
|
+
activeTurnHostCalls !== undefined ||
|
|
3304
|
+
activeTurnSummary !== undefined ||
|
|
3305
|
+
runFailureFacts !== undefined ||
|
|
3306
|
+
servingCall !== undefined ||
|
|
3307
|
+
decisionCall !== undefined ||
|
|
3308
|
+
captainQueue.pending !== 0 ||
|
|
3309
|
+
captainQueue.size !== 0 ||
|
|
3310
|
+
(mode !== 'chat' && mode !== 'engaged.parked')) {
|
|
3311
|
+
return false;
|
|
3312
|
+
}
|
|
3313
|
+
if ((mode === 'chat' &&
|
|
3314
|
+
(frames.length !== 0 ||
|
|
3315
|
+
pendingChildParents.size !== 0 ||
|
|
3316
|
+
pendingBossQuestions !== undefined ||
|
|
3317
|
+
lastError !== undefined)) ||
|
|
3318
|
+
(mode === 'engaged.parked' && frames.length === 0)) {
|
|
3319
|
+
return false;
|
|
3320
|
+
}
|
|
3321
|
+
const expectedParents = new Set(frames.slice(0, -1));
|
|
3322
|
+
if (pendingChildParents.size !== expectedParents.size ||
|
|
3323
|
+
[...pendingChildParents].some((frame) => !expectedParents.has(frame))) {
|
|
3324
|
+
return false;
|
|
3325
|
+
}
|
|
3326
|
+
return frames.every((frame, index) => {
|
|
3327
|
+
const liveInvocation = frame.invocationSignal !== undefined || frame.abortListener !== undefined;
|
|
3328
|
+
return (frame.state !== undefined &&
|
|
3329
|
+
frame.state.status === 'active' &&
|
|
3330
|
+
frame.state.quiescent &&
|
|
3331
|
+
!frame.disposing &&
|
|
3332
|
+
frame.disposePromise === undefined &&
|
|
3333
|
+
frame.removal === undefined &&
|
|
3334
|
+
frame.inFlightHostCalls.size === 0 &&
|
|
3335
|
+
(index === 0
|
|
3336
|
+
? !liveInvocation
|
|
3337
|
+
: !liveInvocation ||
|
|
3338
|
+
(frame.invocationSignal !== undefined &&
|
|
3339
|
+
!frame.invocationSignal.aborted &&
|
|
3340
|
+
frame.abortListener !== undefined)));
|
|
3341
|
+
});
|
|
3342
|
+
};
|
|
3343
|
+
const exportShellSnapshot = () => {
|
|
3344
|
+
if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
|
|
3345
|
+
return undefined;
|
|
3346
|
+
}
|
|
3347
|
+
try {
|
|
3348
|
+
if (typeof captainRuntime.exportSnapshot !== 'function' ||
|
|
3349
|
+
typeof captainRuntime.restore !== 'function') {
|
|
3350
|
+
return undefined;
|
|
3351
|
+
}
|
|
3352
|
+
const captainSnapshot = captainRuntime.exportSnapshot();
|
|
3353
|
+
if (captainSnapshot === undefined)
|
|
3354
|
+
return undefined;
|
|
3355
|
+
const frameSnapshots = [];
|
|
3356
|
+
for (const frame of frames) {
|
|
3357
|
+
if (typeof frame.runtime.exportSnapshot !== 'function' ||
|
|
3358
|
+
typeof frame.runtime.restore !== 'function') {
|
|
3359
|
+
return undefined;
|
|
3360
|
+
}
|
|
3361
|
+
const runtime = frame.runtime.exportSnapshot();
|
|
3362
|
+
if (runtime === undefined ||
|
|
3363
|
+
!isDeepStrictEqual(frame.state, runtime.state)) {
|
|
3364
|
+
return undefined;
|
|
3365
|
+
}
|
|
3366
|
+
frameSnapshots.push({
|
|
3367
|
+
playbookId: frame.entry.id,
|
|
3368
|
+
sessionId: frame.sessionId,
|
|
3369
|
+
rootSessionId: frame.rootSessionId,
|
|
3370
|
+
depth: frame.depth,
|
|
3371
|
+
...(frame.parent
|
|
3372
|
+
? {
|
|
3373
|
+
parentSessionId: frame.parent.frame.sessionId,
|
|
3374
|
+
parentCallId: frame.parent.callId,
|
|
3375
|
+
}
|
|
3376
|
+
: {}),
|
|
3377
|
+
runtime,
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
const common = {
|
|
3381
|
+
schemaVersion: 1,
|
|
3382
|
+
captain: {
|
|
3383
|
+
sessionId: captainSessionId,
|
|
3384
|
+
runtime: captainSnapshot,
|
|
3385
|
+
conversation,
|
|
3386
|
+
},
|
|
3387
|
+
issuedSessionIds: [...issuedSessionIds],
|
|
3388
|
+
sequences: { turn: turnSequence, journal: journalSeq },
|
|
3389
|
+
journal,
|
|
3390
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
3391
|
+
...(lastSettlementStatus === undefined
|
|
3392
|
+
? {}
|
|
3393
|
+
: { lastSettlementStatus }),
|
|
3394
|
+
};
|
|
3395
|
+
const candidate = mode === 'chat'
|
|
3396
|
+
? { ...common, mode }
|
|
3397
|
+
: {
|
|
3398
|
+
...common,
|
|
3399
|
+
mode: 'engaged.parked',
|
|
3400
|
+
frames: frameSnapshots,
|
|
3401
|
+
rootPlayerResumeTokens: tokenRecord(rootFrame().playerResumeTokens),
|
|
3402
|
+
...(pendingBossQuestions === undefined
|
|
3403
|
+
? {}
|
|
3404
|
+
: { pendingBossQuestions: pendingBossQuestions }),
|
|
3405
|
+
...(lastError === undefined ? {} : { lastError }),
|
|
3406
|
+
};
|
|
3407
|
+
const normalized = assertPlaybookCaptainShellSnapshot(candidate);
|
|
3408
|
+
assertSnapshotMatchesEnablements(normalized, enablementById);
|
|
3409
|
+
return normalized;
|
|
3410
|
+
}
|
|
3411
|
+
catch {
|
|
3412
|
+
return undefined;
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
const verifyRestoredRuntime = (runtime, expected, playbookId, allowSuspendedCall) => {
|
|
3416
|
+
const actual = runtime.exportSnapshot?.();
|
|
3417
|
+
if (actual === undefined) {
|
|
3418
|
+
throw new Error(`restored ${playbookId} runtime did not reach a safe snapshot boundary`);
|
|
3419
|
+
}
|
|
3420
|
+
const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
|
|
3421
|
+
for (const key of [
|
|
3422
|
+
'state',
|
|
3423
|
+
'playerResumeTokens',
|
|
3424
|
+
'sequences',
|
|
3425
|
+
'pendingBossQuestions',
|
|
3426
|
+
'suspendedCall',
|
|
3427
|
+
]) {
|
|
3428
|
+
if (!isDeepStrictEqual(normalized[key], expected[key])) {
|
|
3429
|
+
throw new Error(`restored ${playbookId} runtime changed snapshot field ${key}`);
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
};
|
|
3433
|
+
const resetFailedRestore = async () => {
|
|
3434
|
+
const cleanupFailures = [];
|
|
3435
|
+
for (const frame of [...frames].reverse()) {
|
|
3436
|
+
frame.disposing = true;
|
|
3437
|
+
try {
|
|
3438
|
+
await frame.runtime.dispose();
|
|
3439
|
+
}
|
|
3440
|
+
catch (error) {
|
|
3441
|
+
cleanupFailures.push(error);
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
if (captainRuntime) {
|
|
3445
|
+
shuttingDown = true;
|
|
3446
|
+
try {
|
|
3447
|
+
await captainRuntime.dispose();
|
|
3448
|
+
}
|
|
3449
|
+
catch (error) {
|
|
3450
|
+
cleanupFailures.push(error);
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
frames.splice(0);
|
|
3454
|
+
pendingChildParents.clear();
|
|
3455
|
+
issuedSessionIds.clear();
|
|
3456
|
+
journal.splice(0);
|
|
3457
|
+
entries = [];
|
|
3458
|
+
byCommand = new Map();
|
|
3459
|
+
byId = new Map();
|
|
3460
|
+
enablementById = new Map();
|
|
3461
|
+
players = [];
|
|
3462
|
+
session = undefined;
|
|
3463
|
+
sessionEmissionsOpen = false;
|
|
3464
|
+
closedGateAttempted = false;
|
|
3465
|
+
captainRuntime = undefined;
|
|
3466
|
+
captainSessionId = undefined;
|
|
3467
|
+
conversation = { kind: 'unopened' };
|
|
3468
|
+
mode = 'chat';
|
|
3469
|
+
pendingBossQuestions = undefined;
|
|
3470
|
+
lastError = undefined;
|
|
3471
|
+
journalSeq = 0;
|
|
3472
|
+
turnSequence = 0;
|
|
3473
|
+
lastAction = undefined;
|
|
3474
|
+
lastSettlementStatus = undefined;
|
|
3475
|
+
shuttingDown = false;
|
|
3476
|
+
if (cleanupFailures.length > 0) {
|
|
3477
|
+
terminallyDisposed = true;
|
|
3478
|
+
lifecycle = 'closed';
|
|
3479
|
+
}
|
|
3480
|
+
else {
|
|
3481
|
+
lifecycle = 'fresh';
|
|
3482
|
+
}
|
|
3483
|
+
return cleanupFailures;
|
|
3484
|
+
};
|
|
3485
|
+
const restoreShellSnapshot = async (initSession, untrusted) => {
|
|
3486
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
3487
|
+
throw new Error('Captain shell restore requires a fresh shell');
|
|
3488
|
+
}
|
|
3489
|
+
if (initSession.signal.aborted) {
|
|
3490
|
+
throw new Error('cannot restore an aborted Captain session');
|
|
3491
|
+
}
|
|
3492
|
+
lifecycle = 'restoring';
|
|
3493
|
+
try {
|
|
3494
|
+
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
3495
|
+
const built = await buildEnablements(options, initSession.players, loadModule);
|
|
3496
|
+
for (const enablement of built.enablementById.values()) {
|
|
3497
|
+
enablement.entry.validateOptions(enablement.optionInput);
|
|
3498
|
+
}
|
|
3499
|
+
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
3500
|
+
installSession(initSession, false);
|
|
2806
3501
|
players = initSession.players;
|
|
2807
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
2808
3502
|
entries = built.entries;
|
|
2809
3503
|
byCommand = built.byCommand;
|
|
2810
3504
|
byId = built.byId;
|
|
2811
3505
|
enablementById = built.enablementById;
|
|
2812
|
-
for (const enablement of enablementById.values()) {
|
|
2813
|
-
enablement.entry.validateOptions(enablement.optionInput);
|
|
2814
|
-
}
|
|
2815
|
-
await setMode('chat', 'init');
|
|
2816
|
-
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
2817
|
-
// engagement stack, with its own playbook session id.
|
|
2818
|
-
const catalog = Object.freeze(entries.map((entry) => Object.freeze({
|
|
2819
|
-
id: entry.id,
|
|
2820
|
-
command: enablementById.get(entry.id).command,
|
|
2821
|
-
intent: entry.intent,
|
|
2822
|
-
})));
|
|
2823
|
-
captainSessionId = allocateSessionId();
|
|
2824
3506
|
captainRuntime = createCaptainRuntime({
|
|
2825
|
-
enabledPlaybooks:
|
|
3507
|
+
enabledPlaybooks: enabledCatalog(),
|
|
2826
3508
|
controller,
|
|
2827
3509
|
});
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
3510
|
+
if (typeof captainRuntime.restore !== 'function') {
|
|
3511
|
+
throw new Error('session Captain runtime does not support restore');
|
|
3512
|
+
}
|
|
3513
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
3514
|
+
const rootTokens = new Map(Object.entries(snapshot.rootPlayerResumeTokens));
|
|
3515
|
+
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
3516
|
+
const parentFrame = frames.at(-1);
|
|
3517
|
+
const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, rootTokens, index === 0
|
|
3518
|
+
? undefined
|
|
3519
|
+
: {
|
|
3520
|
+
frame: parentFrame,
|
|
3521
|
+
callId: frameSnapshot.parentCallId,
|
|
3522
|
+
});
|
|
3523
|
+
if (typeof frame.runtime.restore !== 'function') {
|
|
3524
|
+
throw new Error(`playbook ${frame.entry.id} runtime does not support restore`);
|
|
3525
|
+
}
|
|
3526
|
+
frames.push(frame);
|
|
3527
|
+
if (parentFrame)
|
|
3528
|
+
pendingChildParents.add(parentFrame);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
|
|
3532
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
3533
|
+
for (const [index, frame] of frames.entries()) {
|
|
3534
|
+
await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
if (closedGateAttempted) {
|
|
3538
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
3539
|
+
}
|
|
3540
|
+
verifyRestoredRuntime(captainRuntime, snapshot.captain.runtime, INTERNAL_CAPTAIN_ID, false);
|
|
3541
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
3542
|
+
for (const [index, frame] of frames.entries()) {
|
|
3543
|
+
verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
|
|
3544
|
+
}
|
|
3545
|
+
if (!isDeepStrictEqual(tokenRecord(rootFrame().playerResumeTokens), snapshot.rootPlayerResumeTokens)) {
|
|
3546
|
+
throw new Error('restored root-owned player continuation changed during restore');
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
if (closedGateAttempted) {
|
|
3550
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
3551
|
+
}
|
|
3552
|
+
if (requireSession().signal.aborted) {
|
|
3553
|
+
throw new Error('Captain session aborted during restore');
|
|
3554
|
+
}
|
|
3555
|
+
for (const id of snapshot.issuedSessionIds)
|
|
3556
|
+
issuedSessionIds.add(id);
|
|
3557
|
+
journal.push(...snapshot.journal);
|
|
3558
|
+
journalSeq = snapshot.sequences.journal;
|
|
3559
|
+
turnSequence = snapshot.sequences.turn;
|
|
3560
|
+
conversation = snapshot.captain.conversation;
|
|
3561
|
+
captainSessionId = snapshot.captain.sessionId;
|
|
3562
|
+
lastAction = snapshot.lastAction;
|
|
3563
|
+
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
3564
|
+
mode = snapshot.mode;
|
|
3565
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
3566
|
+
pendingBossQuestions = snapshot.pendingBossQuestions;
|
|
3567
|
+
lastError = snapshot.lastError;
|
|
3568
|
+
}
|
|
3569
|
+
lifecycle = 'ready';
|
|
3570
|
+
// The final commit is deliberately one non-throwing assignment.
|
|
3571
|
+
sessionEmissionsOpen = true;
|
|
3572
|
+
}
|
|
3573
|
+
catch (error) {
|
|
3574
|
+
const cleanupFailures = await resetFailedRestore();
|
|
3575
|
+
if (cleanupFailures.length > 0) {
|
|
3576
|
+
throw new AggregateError([error, ...cleanupFailures], 'Captain shell restore and cleanup failed');
|
|
3577
|
+
}
|
|
3578
|
+
throw error;
|
|
3579
|
+
}
|
|
3580
|
+
};
|
|
3581
|
+
return {
|
|
3582
|
+
async init(initSession) {
|
|
3583
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
3584
|
+
throw new Error('Captain shell requires a fresh instance for init');
|
|
3585
|
+
}
|
|
3586
|
+
if (initSession.signal.aborted) {
|
|
3587
|
+
throw new Error('cannot initialize an aborted Captain session');
|
|
3588
|
+
}
|
|
3589
|
+
lifecycle = 'initializing';
|
|
3590
|
+
try {
|
|
3591
|
+
installSession(initSession, true);
|
|
3592
|
+
players = initSession.players;
|
|
3593
|
+
const built = await buildEnablements(options, players, loadModule);
|
|
3594
|
+
entries = built.entries;
|
|
3595
|
+
byCommand = built.byCommand;
|
|
3596
|
+
byId = built.byId;
|
|
3597
|
+
enablementById = built.enablementById;
|
|
3598
|
+
for (const enablement of enablementById.values()) {
|
|
3599
|
+
enablement.entry.validateOptions(enablement.optionInput);
|
|
3600
|
+
}
|
|
3601
|
+
await setMode('chat', 'init');
|
|
3602
|
+
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
3603
|
+
// engagement stack, with its own playbook session id.
|
|
3604
|
+
captainSessionId = allocateSessionId();
|
|
3605
|
+
captainRuntime = createCaptainRuntime({
|
|
3606
|
+
enabledPlaybooks: enabledCatalog(),
|
|
3607
|
+
controller,
|
|
3608
|
+
});
|
|
3609
|
+
await captainRuntime.init(captainPlaybookSession(captainSessionId));
|
|
3610
|
+
lifecycle = 'ready';
|
|
3611
|
+
}
|
|
3612
|
+
catch (error) {
|
|
3613
|
+
terminallyDisposed = true;
|
|
3614
|
+
lifecycle = 'closed';
|
|
3615
|
+
throw error;
|
|
3616
|
+
}
|
|
2835
3617
|
},
|
|
3618
|
+
exportSnapshot: exportShellSnapshot,
|
|
3619
|
+
restore: restoreShellSnapshot,
|
|
2836
3620
|
async handleBossTurn(turn, context) {
|
|
3621
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
3622
|
+
throw new Error('init must be called first, or restore must complete before handling a Boss turn');
|
|
3623
|
+
}
|
|
2837
3624
|
requireSession();
|
|
2838
3625
|
if (!captainRuntime) {
|
|
2839
3626
|
throw new Error('init must be called first');
|
|
@@ -2926,10 +3713,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2926
3713
|
}
|
|
2927
3714
|
},
|
|
2928
3715
|
async prepareDispose() {
|
|
3716
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
3717
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
3718
|
+
}
|
|
2929
3719
|
activeContext = undefined;
|
|
2930
3720
|
await teardown();
|
|
2931
3721
|
},
|
|
2932
3722
|
async dispose() {
|
|
3723
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
3724
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
3725
|
+
}
|
|
2933
3726
|
activeContext = undefined;
|
|
2934
3727
|
await teardown();
|
|
2935
3728
|
},
|
|
@@ -2937,6 +3730,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2937
3730
|
// CAPTAIN-16: dispose every active frame from leaf to root, then the
|
|
2938
3731
|
// session Captain last.
|
|
2939
3732
|
async function teardown() {
|
|
3733
|
+
terminallyDisposed = true;
|
|
3734
|
+
lifecycle = 'disposing';
|
|
2940
3735
|
let failure;
|
|
2941
3736
|
try {
|
|
2942
3737
|
await disposeStack('dispose');
|
|
@@ -2955,6 +3750,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2955
3750
|
failure ??= error;
|
|
2956
3751
|
}
|
|
2957
3752
|
}
|
|
3753
|
+
lifecycle = 'closed';
|
|
2958
3754
|
if (failure !== undefined)
|
|
2959
3755
|
throw failure;
|
|
2960
3756
|
}
|