@sublang/playbook 5.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 +22 -14
- package/docs/cli.md +138 -90
- package/docs/configuration.md +113 -29
- package/docs/embedding.md +24 -16
- package/package.json +42 -21
- package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
- package/reference/sdlc/code.md +55 -97
- 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/code.fsm.d.ts +229 -94
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
- package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
- package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
- package/reference/sdlc/code.playbook/code.gears.md +51 -263
- package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
- package/reference/sdlc/code.playbook/code.playbook.js +69 -656
- package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
- package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
- package/reference/sdlc/code.playbook/code.registry.js +20 -78
- package/reference/sdlc/code.playbook/code.registry.ts +58 -122
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
- package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
- package/reference/sdlc/decide.md +54 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
- package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
- package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +545 -372
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
- package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
- package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
- package/reference/sdlc/review.md +81 -0
- package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
- package/reference/sdlc/review.playbook/review.fsm.js +524 -0
- package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
- package/reference/sdlc/review.playbook/review.gears.md +112 -0
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
- package/reference/sdlc/review.playbook/review.playbook.js +112 -0
- package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
- package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
- package/reference/sdlc/review.playbook/review.registry.js +73 -0
- package/reference/sdlc/review.playbook/review.registry.ts +138 -0
- package/slc/gears2fsm.md +13 -4
- package/slc/link.md +83 -14
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +21 -2
- package/src/runtime.ts +38 -6
- package/src/xstate-playbook-runtime.d.ts +9 -2
- package/src/xstate-playbook-runtime.js +319 -35
- package/src/xstate-playbook-runtime.ts +412 -41
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +384 -57
- package/src/xstate-runtime.ts +542 -71
- package/reference/sdlc/discuss.md +0 -93
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
- package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
- package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
|
@@ -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;
|
|
@@ -495,7 +757,6 @@ async function buildEnablements(options, players, loadModule) {
|
|
|
495
757
|
optionInput: record.options,
|
|
496
758
|
boundPlayers,
|
|
497
759
|
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
498
|
-
visiblePlayerIds: entry.requiredRoleIds.map((role) => `${entry.id}-${role}`),
|
|
499
760
|
});
|
|
500
761
|
}
|
|
501
762
|
return { entries, byCommand, byId, enablementById };
|
|
@@ -513,6 +774,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
513
774
|
let byId = new Map();
|
|
514
775
|
let enablementById = new Map();
|
|
515
776
|
let session;
|
|
777
|
+
let sessionEmissionsOpen = false;
|
|
778
|
+
let closedGateAttempted = false;
|
|
779
|
+
let lifecycle = 'fresh';
|
|
780
|
+
let terminallyDisposed = false;
|
|
516
781
|
let players = [];
|
|
517
782
|
let activeContext;
|
|
518
783
|
const frames = [];
|
|
@@ -525,6 +790,41 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
525
790
|
const pendingChildParents = new Set();
|
|
526
791
|
const captainQueue = new PQueue({ concurrency: 1 });
|
|
527
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
|
+
};
|
|
528
828
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
529
829
|
let captainRuntime;
|
|
530
830
|
let captainSessionId;
|
|
@@ -555,6 +855,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
555
855
|
const rootFrame = () => frames[0];
|
|
556
856
|
const leafFrame = () => frames.at(-1);
|
|
557
857
|
const frameLabel = (frame) => `/${frame.enablement.command}`;
|
|
858
|
+
const bindingFor = (frame, localRole) => {
|
|
859
|
+
const binding = frame.playerBindings.get(localRole);
|
|
860
|
+
if (!binding) {
|
|
861
|
+
throw new Error(`${frameLabel(frame)} resolved undeclared player role ${JSON.stringify(localRole)}`);
|
|
862
|
+
}
|
|
863
|
+
return binding;
|
|
864
|
+
};
|
|
865
|
+
const summaryIncludes = (frame) => {
|
|
866
|
+
const owner = activeTurnSummary?.owner;
|
|
867
|
+
if (!owner)
|
|
868
|
+
return false;
|
|
869
|
+
for (let current = frame; current;) {
|
|
870
|
+
if (current === owner)
|
|
871
|
+
return true;
|
|
872
|
+
current = current.parent?.frame;
|
|
873
|
+
}
|
|
874
|
+
return false;
|
|
875
|
+
};
|
|
558
876
|
const requireSession = () => {
|
|
559
877
|
if (!session) {
|
|
560
878
|
throw new Error('init must be called first');
|
|
@@ -717,7 +1035,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
717
1035
|
return;
|
|
718
1036
|
const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
|
|
719
1037
|
frame.state = state;
|
|
720
|
-
|
|
1038
|
+
const summary = activeTurnSummary;
|
|
1039
|
+
if (summary && summaryIncludes(frame)) {
|
|
721
1040
|
for (const stateId of state.activeStateIds) {
|
|
722
1041
|
const newlyActive = !previousActiveIds.has(stateId);
|
|
723
1042
|
const structuredEntry = stateValueContains(record.to, stateId) &&
|
|
@@ -726,7 +1045,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
726
1045
|
continue;
|
|
727
1046
|
const countLabel = stateCountLabel(stateId, frame.entry);
|
|
728
1047
|
if (countLabel) {
|
|
729
|
-
|
|
1048
|
+
summary.stateCounts.set(countLabel, (summary.stateCounts.get(countLabel) ?? 0) + 1);
|
|
730
1049
|
}
|
|
731
1050
|
}
|
|
732
1051
|
}
|
|
@@ -742,12 +1061,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
742
1061
|
let callNestedPlaybook;
|
|
743
1062
|
const createPorts = (frame) => ({
|
|
744
1063
|
callPlayer: async (playerId, prompt, signal, options) => {
|
|
1064
|
+
admitHostBoundary();
|
|
745
1065
|
if (!activeContext) {
|
|
746
1066
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
747
1067
|
}
|
|
748
1068
|
const context = activeContext;
|
|
749
1069
|
signal.throwIfAborted();
|
|
750
|
-
const hostPlayerId = frame.
|
|
1070
|
+
const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
|
|
751
1071
|
const result = await trackHostCall(frame, context.callPlayer(hostPlayerId, prompt, {
|
|
752
1072
|
resume: options.resume,
|
|
753
1073
|
}));
|
|
@@ -759,8 +1079,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
759
1079
|
// CAPTAIN-20: only a player call that actually produced work is an
|
|
760
1080
|
// interruption the Boss was spared. A call that errored or aborted
|
|
761
1081
|
// saved nothing, so it never feeds the saved-counts gate.
|
|
762
|
-
|
|
763
|
-
|
|
1082
|
+
const summary = activeTurnSummary;
|
|
1083
|
+
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
1084
|
+
summary.counts.interruptions++;
|
|
764
1085
|
}
|
|
765
1086
|
return {
|
|
766
1087
|
status: result.status,
|
|
@@ -774,6 +1095,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
774
1095
|
};
|
|
775
1096
|
},
|
|
776
1097
|
callCaptain: async (prompt, signal, options) => {
|
|
1098
|
+
admitHostBoundary();
|
|
777
1099
|
if (!activeContext) {
|
|
778
1100
|
throw new Error('callCaptain invoked outside a Boss turn');
|
|
779
1101
|
}
|
|
@@ -791,6 +1113,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
791
1113
|
};
|
|
792
1114
|
},
|
|
793
1115
|
callJudge: async (prompt, signal) => {
|
|
1116
|
+
admitHostBoundary();
|
|
794
1117
|
if (!activeContext) {
|
|
795
1118
|
throw new Error('callJudge invoked outside a Boss turn');
|
|
796
1119
|
}
|
|
@@ -806,14 +1129,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
806
1129
|
throw new Error('callCaptain returned status=ok with no finalText');
|
|
807
1130
|
}
|
|
808
1131
|
const guard = guardFromJudgeReply(result.finalText);
|
|
1132
|
+
const summary = activeTurnSummary;
|
|
809
1133
|
if (guard &&
|
|
810
|
-
|
|
1134
|
+
summary &&
|
|
1135
|
+
summaryIncludes(frame) &&
|
|
811
1136
|
frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)) {
|
|
812
|
-
|
|
1137
|
+
summary.counts.copyPastes++;
|
|
813
1138
|
}
|
|
814
1139
|
return result.finalText;
|
|
815
1140
|
},
|
|
816
1141
|
callPlaybook: (request, signal) => {
|
|
1142
|
+
admitHostBoundary();
|
|
817
1143
|
const opening = callNestedPlaybook(frame, request, signal);
|
|
818
1144
|
let exposed;
|
|
819
1145
|
const registerOpeningCleanup = () => {
|
|
@@ -827,22 +1153,31 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
827
1153
|
registerOpeningCleanup();
|
|
828
1154
|
return exposed;
|
|
829
1155
|
},
|
|
830
|
-
emitStatus:
|
|
831
|
-
|
|
1156
|
+
emitStatus: (message, data) => {
|
|
1157
|
+
if (!admitHostEmission())
|
|
1158
|
+
return Promise.resolve();
|
|
1159
|
+
return trackHostCall(frame, (async () => {
|
|
1160
|
+
await requireSession().emitStatus(message, data);
|
|
1161
|
+
})());
|
|
832
1162
|
},
|
|
833
|
-
emitTelemetry:
|
|
834
|
-
if (
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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);
|
|
838
1173
|
},
|
|
839
1174
|
});
|
|
840
1175
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
841
1176
|
// visibility for that playbook's generated host players. A pane
|
|
842
1177
|
// reconciliation failure is display-only in tmux-play and does not
|
|
843
1178
|
// reject; the legacy path carries no generated set and skips this.
|
|
844
|
-
const requestVisibility = async (
|
|
845
|
-
const ids =
|
|
1179
|
+
const requestVisibility = async (frame) => {
|
|
1180
|
+
const ids = [...new Set([...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId))];
|
|
846
1181
|
if (!ids || ids.length === 0 || !activeContext)
|
|
847
1182
|
return;
|
|
848
1183
|
try {
|
|
@@ -875,12 +1210,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
875
1210
|
: undefined;
|
|
876
1211
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
877
1212
|
};
|
|
1213
|
+
const makePlayerBindings = (enablement, parent) => {
|
|
1214
|
+
const entry = enablement.entry;
|
|
1215
|
+
const playerBindings = new Map();
|
|
1216
|
+
for (const role of entry.requiredRoleIds) {
|
|
1217
|
+
let inherited;
|
|
1218
|
+
for (let ancestor = parent?.frame; ancestor && inherited === undefined; ancestor = ancestor.parent?.frame) {
|
|
1219
|
+
inherited = ancestor.playerBindings.get(role);
|
|
1220
|
+
}
|
|
1221
|
+
if (inherited) {
|
|
1222
|
+
playerBindings.set(role, inherited);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const configured = enablement.boundPlayers.find((player) => player.id === role) ?? { id: role };
|
|
1226
|
+
playerBindings.set(role, {
|
|
1227
|
+
hostPlayerId: enablement.hostPlayerId(role),
|
|
1228
|
+
player: configured,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return playerBindings;
|
|
1232
|
+
};
|
|
878
1233
|
const makeFrame = (enablement, parent) => {
|
|
879
1234
|
const entry = enablement.entry;
|
|
880
1235
|
const sessionId = allocateSessionId();
|
|
1236
|
+
const playerBindings = makePlayerBindings(enablement, parent);
|
|
1237
|
+
const playerResumeTokens = parent?.frame.playerResumeTokens ?? new Map();
|
|
881
1238
|
const runtime = entry.createRuntime({
|
|
882
1239
|
captainOptions: enablement.optionInput,
|
|
883
|
-
players:
|
|
1240
|
+
players: [...playerBindings].map(([role, { player }]) => ({
|
|
1241
|
+
id: role,
|
|
1242
|
+
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
1243
|
+
...(player.model === undefined ? {} : { model: player.model }),
|
|
1244
|
+
})),
|
|
884
1245
|
});
|
|
885
1246
|
return {
|
|
886
1247
|
entry,
|
|
@@ -889,24 +1250,86 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
889
1250
|
sessionId,
|
|
890
1251
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
891
1252
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
1253
|
+
playerBindings,
|
|
1254
|
+
playerResumeTokens,
|
|
892
1255
|
...(parent ? { parent } : {}),
|
|
893
1256
|
inFlightHostCalls: new Set(),
|
|
894
1257
|
};
|
|
895
1258
|
};
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
...(
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
: {}),
|
|
907
|
-
depth: frame.depth,
|
|
908
|
-
ports: createPorts(frame),
|
|
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
|
+
})),
|
|
909
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
|
+
};
|
|
1284
|
+
const playerSessionStore = (frame) => ({
|
|
1285
|
+
select(playerId) {
|
|
1286
|
+
const binding = bindingFor(frame, playerId);
|
|
1287
|
+
return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
|
|
1288
|
+
},
|
|
1289
|
+
update(playerId, resumeToken) {
|
|
1290
|
+
const binding = bindingFor(frame, playerId);
|
|
1291
|
+
if (resumeToken === undefined) {
|
|
1292
|
+
frame.playerResumeTokens.delete(binding.hostPlayerId);
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
snapshot() {
|
|
1299
|
+
const tokens = {};
|
|
1300
|
+
for (const [playerId, binding] of frame.playerBindings) {
|
|
1301
|
+
const token = frame.playerResumeTokens.get(binding.hostPlayerId);
|
|
1302
|
+
if (token !== undefined)
|
|
1303
|
+
tokens[playerId] = token;
|
|
1304
|
+
}
|
|
1305
|
+
return tokens;
|
|
1306
|
+
},
|
|
1307
|
+
restore(tokens) {
|
|
1308
|
+
for (const binding of frame.playerBindings.values()) {
|
|
1309
|
+
frame.playerResumeTokens.delete(binding.hostPlayerId);
|
|
1310
|
+
}
|
|
1311
|
+
for (const [playerId, token] of Object.entries(tokens)) {
|
|
1312
|
+
const binding = bindingFor(frame, playerId);
|
|
1313
|
+
frame.playerResumeTokens.set(binding.hostPlayerId, token);
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
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
|
+
});
|
|
1331
|
+
const initFrame = async (frame) => {
|
|
1332
|
+
await frame.runtime.init(frameSession(frame));
|
|
910
1333
|
};
|
|
911
1334
|
const clearLeafLedger = () => {
|
|
912
1335
|
pendingBossQuestions = undefined;
|
|
@@ -1076,7 +1499,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1076
1499
|
}
|
|
1077
1500
|
let visibilityError;
|
|
1078
1501
|
try {
|
|
1079
|
-
await requestVisibility(parent
|
|
1502
|
+
await requestVisibility(parent);
|
|
1080
1503
|
}
|
|
1081
1504
|
catch (error) {
|
|
1082
1505
|
visibilityError = error;
|
|
@@ -1184,7 +1607,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1184
1607
|
// boundary — a `setVisiblePlayers` or telemetry rejection here leaves the
|
|
1185
1608
|
// runtime uninvoked and owes the Boss the CAPTAIN-34 reply rather than an
|
|
1186
1609
|
// exception filed against an effect that never ran.
|
|
1187
|
-
await requestVisibility(frame
|
|
1610
|
+
await requestVisibility(frame);
|
|
1188
1611
|
await setMode('engaged.driving', 'submit');
|
|
1189
1612
|
const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
|
|
1190
1613
|
frame.state = result.state;
|
|
@@ -1268,6 +1691,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1268
1691
|
await resumeParent(frame, callResultFor(frame, result), context);
|
|
1269
1692
|
}
|
|
1270
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));
|
|
1271
1700
|
await runEffect(() => disposeStack('final'));
|
|
1272
1701
|
}
|
|
1273
1702
|
return;
|
|
@@ -1306,7 +1735,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1306
1735
|
cleanupError = error;
|
|
1307
1736
|
}
|
|
1308
1737
|
if (frames.includes(parent)) {
|
|
1309
|
-
await requestVisibility(parent
|
|
1738
|
+
await requestVisibility(parent);
|
|
1310
1739
|
}
|
|
1311
1740
|
if (cleanupError !== undefined)
|
|
1312
1741
|
throw cleanupError;
|
|
@@ -1418,7 +1847,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1418
1847
|
}
|
|
1419
1848
|
if (frames.includes(parent)) {
|
|
1420
1849
|
try {
|
|
1421
|
-
await requestVisibility(parent
|
|
1850
|
+
await requestVisibility(parent);
|
|
1422
1851
|
}
|
|
1423
1852
|
catch (visibilityError) {
|
|
1424
1853
|
visibilityControlFailure = true;
|
|
@@ -1947,9 +2376,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1947
2376
|
// -------------------------------------------------------------------------
|
|
1948
2377
|
const captainPorts = () => ({
|
|
1949
2378
|
callPlayer: async () => {
|
|
2379
|
+
admitHostBoundary();
|
|
1950
2380
|
throw new Error('the session Captain has no players');
|
|
1951
2381
|
},
|
|
1952
2382
|
callCaptain: async (prompt, signal) => {
|
|
2383
|
+
admitHostBoundary();
|
|
1953
2384
|
if (!activeContext) {
|
|
1954
2385
|
throw new Error('the session Captain called out of a Boss turn');
|
|
1955
2386
|
}
|
|
@@ -2007,29 +2438,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2007
2438
|
return { status: 'ok', finalText: 'ok' };
|
|
2008
2439
|
},
|
|
2009
2440
|
callJudge: async () => {
|
|
2441
|
+
admitHostBoundary();
|
|
2010
2442
|
throw new Error('the session Captain makes no judge call');
|
|
2011
2443
|
},
|
|
2012
2444
|
callPlaybook: async () => {
|
|
2445
|
+
admitHostBoundary();
|
|
2013
2446
|
throw new Error('the session Captain never calls a playbook');
|
|
2014
2447
|
},
|
|
2015
2448
|
// CAPTAIN-9: the session Captain's human status stream is suppressed while
|
|
2016
2449
|
// its structured telemetry is forwarded.
|
|
2017
|
-
emitStatus: async () => {
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
+
}
|
|
2030
2469
|
}
|
|
2031
|
-
|
|
2032
|
-
|
|
2470
|
+
await requireSession().emitTelemetry(event);
|
|
2471
|
+
})();
|
|
2472
|
+
return trackTurnCall(emission);
|
|
2033
2473
|
},
|
|
2034
2474
|
});
|
|
2035
2475
|
const resolveCommandTurn = (text) => {
|
|
@@ -2097,6 +2537,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2097
2537
|
return undefined;
|
|
2098
2538
|
}
|
|
2099
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
|
+
};
|
|
2100
2547
|
const leafStateSummary = () => {
|
|
2101
2548
|
const leaf = leafFrame();
|
|
2102
2549
|
if (!leaf)
|
|
@@ -2495,9 +2942,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2495
2942
|
};
|
|
2496
2943
|
throw outcome.error;
|
|
2497
2944
|
}
|
|
2498
|
-
if (!frames.includes(leaf)) {
|
|
2499
|
-
facts.push(`${frameLabel(leaf)} finished and was disposed.`);
|
|
2500
|
-
}
|
|
2501
2945
|
const runFailed = drainRunFailureFacts(facts);
|
|
2502
2946
|
const summary = leafStateSummary();
|
|
2503
2947
|
turn.report = {
|
|
@@ -2652,8 +3096,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2652
3096
|
};
|
|
2653
3097
|
};
|
|
2654
3098
|
const controller = {
|
|
2655
|
-
submit: (selection, signal) =>
|
|
2656
|
-
|
|
3099
|
+
submit: (selection, signal) => {
|
|
3100
|
+
admitHostBoundary();
|
|
3101
|
+
return settleSelection(selection, signal);
|
|
3102
|
+
},
|
|
3103
|
+
resolveParsedTurn: () => {
|
|
3104
|
+
admitHostBoundary();
|
|
3105
|
+
return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
|
|
3106
|
+
},
|
|
2657
3107
|
};
|
|
2658
3108
|
// -------------------------------------------------------------------------
|
|
2659
3109
|
// Failure surface (CAPTAIN-34): a Boss-appropriate reply naming a concrete
|
|
@@ -2722,40 +3172,455 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2722
3172
|
text: failureReplyText(),
|
|
2723
3173
|
});
|
|
2724
3174
|
};
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
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);
|
|
2728
3501
|
players = initSession.players;
|
|
2729
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
2730
3502
|
entries = built.entries;
|
|
2731
3503
|
byCommand = built.byCommand;
|
|
2732
3504
|
byId = built.byId;
|
|
2733
3505
|
enablementById = built.enablementById;
|
|
2734
|
-
for (const enablement of enablementById.values()) {
|
|
2735
|
-
enablement.entry.validateOptions(enablement.optionInput);
|
|
2736
|
-
}
|
|
2737
|
-
await setMode('chat', 'init');
|
|
2738
|
-
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
2739
|
-
// engagement stack, with its own playbook session id.
|
|
2740
|
-
const catalog = Object.freeze(entries.map((entry) => Object.freeze({
|
|
2741
|
-
id: entry.id,
|
|
2742
|
-
command: enablementById.get(entry.id).command,
|
|
2743
|
-
intent: entry.intent,
|
|
2744
|
-
})));
|
|
2745
|
-
captainSessionId = allocateSessionId();
|
|
2746
3506
|
captainRuntime = createCaptainRuntime({
|
|
2747
|
-
enabledPlaybooks:
|
|
3507
|
+
enabledPlaybooks: enabledCatalog(),
|
|
2748
3508
|
controller,
|
|
2749
3509
|
});
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
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
|
+
}
|
|
2757
3617
|
},
|
|
3618
|
+
exportSnapshot: exportShellSnapshot,
|
|
3619
|
+
restore: restoreShellSnapshot,
|
|
2758
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
|
+
}
|
|
2759
3624
|
requireSession();
|
|
2760
3625
|
if (!captainRuntime) {
|
|
2761
3626
|
throw new Error('init must be called first');
|
|
@@ -2848,10 +3713,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2848
3713
|
}
|
|
2849
3714
|
},
|
|
2850
3715
|
async prepareDispose() {
|
|
3716
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
3717
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
3718
|
+
}
|
|
2851
3719
|
activeContext = undefined;
|
|
2852
3720
|
await teardown();
|
|
2853
3721
|
},
|
|
2854
3722
|
async dispose() {
|
|
3723
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
3724
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
3725
|
+
}
|
|
2855
3726
|
activeContext = undefined;
|
|
2856
3727
|
await teardown();
|
|
2857
3728
|
},
|
|
@@ -2859,6 +3730,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2859
3730
|
// CAPTAIN-16: dispose every active frame from leaf to root, then the
|
|
2860
3731
|
// session Captain last.
|
|
2861
3732
|
async function teardown() {
|
|
3733
|
+
terminallyDisposed = true;
|
|
3734
|
+
lifecycle = 'disposing';
|
|
2862
3735
|
let failure;
|
|
2863
3736
|
try {
|
|
2864
3737
|
await disposeStack('dispose');
|
|
@@ -2877,6 +3750,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2877
3750
|
failure ??= error;
|
|
2878
3751
|
}
|
|
2879
3752
|
}
|
|
3753
|
+
lifecycle = 'closed';
|
|
2880
3754
|
if (failure !== undefined)
|
|
2881
3755
|
throw failure;
|
|
2882
3756
|
}
|