@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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import PQueue from 'p-queue';
|
|
6
7
|
|
|
7
8
|
import type {
|
|
@@ -20,12 +21,15 @@ import type {
|
|
|
20
21
|
PlaybookPorts,
|
|
21
22
|
PlaybookRunResult,
|
|
22
23
|
PlaybookRuntime,
|
|
24
|
+
PlaybookRuntimeSnapshot,
|
|
23
25
|
PlayerSessionStore,
|
|
24
26
|
PlaybookState,
|
|
25
27
|
} from '@sublang/playbook/runtime';
|
|
26
28
|
import {
|
|
29
|
+
assertPlaybookRuntimeSnapshot,
|
|
27
30
|
hiddenControlEnvelope,
|
|
28
31
|
registerPlaybookAbortCleanup,
|
|
32
|
+
snapshotJsonValue,
|
|
29
33
|
} from '../../../src/xstate-runtime.js';
|
|
30
34
|
import createDefaultCaptainRuntime, {
|
|
31
35
|
type CaptainControllerPort,
|
|
@@ -63,6 +67,86 @@ export interface PlaybookCaptainRegistryEntry {
|
|
|
63
67
|
createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
type PlaybookCaptainConversationSnapshot =
|
|
71
|
+
| { readonly kind: 'unopened' }
|
|
72
|
+
| { readonly kind: 'pinned'; readonly token: string }
|
|
73
|
+
| { readonly kind: 'needsSeeding' };
|
|
74
|
+
|
|
75
|
+
interface PlaybookCaptainJournalRecord {
|
|
76
|
+
readonly seq: number;
|
|
77
|
+
readonly turnId: number;
|
|
78
|
+
readonly kind: 'boss' | 'reply' | 'handoff' | 'action' | 'outcome';
|
|
79
|
+
readonly payload: JsonValue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PlaybookCaptainFrameSnapshot {
|
|
83
|
+
readonly playbookId: string;
|
|
84
|
+
readonly sessionId: string;
|
|
85
|
+
readonly rootSessionId: string;
|
|
86
|
+
readonly depth: number;
|
|
87
|
+
readonly parentSessionId?: string;
|
|
88
|
+
readonly parentCallId?: string;
|
|
89
|
+
readonly runtime: PlaybookRuntimeSnapshot;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface PlaybookCaptainShellSnapshotFields {
|
|
93
|
+
readonly schemaVersion: 1;
|
|
94
|
+
readonly captain: {
|
|
95
|
+
readonly sessionId: string;
|
|
96
|
+
readonly runtime: PlaybookRuntimeSnapshot;
|
|
97
|
+
readonly conversation: PlaybookCaptainConversationSnapshot;
|
|
98
|
+
};
|
|
99
|
+
/** Every Captain and engagement UUID issued during this logical session. */
|
|
100
|
+
readonly issuedSessionIds: readonly string[];
|
|
101
|
+
readonly sequences: {
|
|
102
|
+
readonly turn: number;
|
|
103
|
+
readonly journal: number;
|
|
104
|
+
};
|
|
105
|
+
readonly journal: readonly PlaybookCaptainJournalRecord[];
|
|
106
|
+
readonly lastAction?:
|
|
107
|
+
| 'respond'
|
|
108
|
+
| 'start'
|
|
109
|
+
| 'switch'
|
|
110
|
+
| 'dismiss'
|
|
111
|
+
| 'deliver'
|
|
112
|
+
| 'runtime';
|
|
113
|
+
readonly lastSettlementStatus?: 'ok' | 'rejected' | 'failed';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Complete JSON-safe durable state for one Captain shell between Boss turns.
|
|
118
|
+
* The discriminated mode keeps chat snapshots free of stale engagement data.
|
|
119
|
+
*/
|
|
120
|
+
export type PlaybookCaptainShellSnapshot =
|
|
121
|
+
PlaybookCaptainShellSnapshotFields &
|
|
122
|
+
(
|
|
123
|
+
| {
|
|
124
|
+
readonly mode: 'chat';
|
|
125
|
+
readonly frames?: never;
|
|
126
|
+
readonly rootPlayerResumeTokens?: never;
|
|
127
|
+
readonly pendingBossQuestions?: never;
|
|
128
|
+
readonly lastError?: never;
|
|
129
|
+
}
|
|
130
|
+
| {
|
|
131
|
+
readonly mode: 'engaged.parked';
|
|
132
|
+
/** Root-to-leaf engagement order. */
|
|
133
|
+
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
134
|
+
/** Root-owned continuation, keyed by effective host-player id. */
|
|
135
|
+
readonly rootPlayerResumeTokens: Readonly<Record<string, string>>;
|
|
136
|
+
readonly pendingBossQuestions?: JsonValue;
|
|
137
|
+
readonly lastError?: { readonly name: string; readonly message: string };
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
/** tmux and headless front ends share this one durable Captain shell API. */
|
|
142
|
+
export interface PlaybookCaptainShell extends Captain {
|
|
143
|
+
exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
|
|
144
|
+
restore(
|
|
145
|
+
session: CaptainSession,
|
|
146
|
+
snapshot: PlaybookCaptainShellSnapshot,
|
|
147
|
+
): Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
|
|
66
150
|
// Per-enabled-playbook binding the shell resolves at init from
|
|
67
151
|
// `captain.options.playbooks`: each playbook binds its local roles to
|
|
68
152
|
// `<id>-<role>` host players and carries the generated visible set.
|
|
@@ -765,6 +849,467 @@ function isValidRegistryEntry(
|
|
|
765
849
|
);
|
|
766
850
|
}
|
|
767
851
|
|
|
852
|
+
const SNAPSHOT_ACTIONS = new Set([
|
|
853
|
+
'respond',
|
|
854
|
+
'start',
|
|
855
|
+
'switch',
|
|
856
|
+
'dismiss',
|
|
857
|
+
'deliver',
|
|
858
|
+
'runtime',
|
|
859
|
+
] as const);
|
|
860
|
+
const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
|
|
861
|
+
'ok',
|
|
862
|
+
'rejected',
|
|
863
|
+
'failed',
|
|
864
|
+
] as const);
|
|
865
|
+
const SNAPSHOT_JOURNAL_KINDS = new Set([
|
|
866
|
+
'boss',
|
|
867
|
+
'reply',
|
|
868
|
+
'handoff',
|
|
869
|
+
'action',
|
|
870
|
+
'outcome',
|
|
871
|
+
] as const);
|
|
872
|
+
|
|
873
|
+
function snapshotRecord(
|
|
874
|
+
value: JsonValue | undefined,
|
|
875
|
+
path: string,
|
|
876
|
+
): Record<string, JsonValue> {
|
|
877
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
878
|
+
throw new TypeError(`${path} must be an object`);
|
|
879
|
+
}
|
|
880
|
+
return value as Record<string, JsonValue>;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function rejectSnapshotKeys(
|
|
884
|
+
value: Readonly<Record<string, JsonValue>>,
|
|
885
|
+
allowed: readonly string[],
|
|
886
|
+
path: string,
|
|
887
|
+
): void {
|
|
888
|
+
const allowedKeys = new Set(allowed);
|
|
889
|
+
const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
890
|
+
if (unknown.length > 0) {
|
|
891
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function snapshotString(
|
|
896
|
+
value: JsonValue | undefined,
|
|
897
|
+
path: string,
|
|
898
|
+
allowEmpty = false,
|
|
899
|
+
): string {
|
|
900
|
+
if (
|
|
901
|
+
typeof value !== 'string' ||
|
|
902
|
+
(!allowEmpty && value.trim().length === 0)
|
|
903
|
+
) {
|
|
904
|
+
throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
|
|
905
|
+
}
|
|
906
|
+
return value;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function snapshotInteger(
|
|
910
|
+
value: JsonValue | undefined,
|
|
911
|
+
path: string,
|
|
912
|
+
minimum = 0,
|
|
913
|
+
): number {
|
|
914
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
|
915
|
+
throw new TypeError(`${path} must be an integer >= ${minimum}`);
|
|
916
|
+
}
|
|
917
|
+
return value as number;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function snapshotUuid(value: JsonValue | undefined, path: string): string {
|
|
921
|
+
const id = snapshotString(value, path);
|
|
922
|
+
if (!UUID_PATTERN.test(id)) {
|
|
923
|
+
throw new TypeError(`${path} must be a UUID`);
|
|
924
|
+
}
|
|
925
|
+
return id;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
929
|
+
function assertPlaybookCaptainShellSnapshot(
|
|
930
|
+
value: unknown,
|
|
931
|
+
): PlaybookCaptainShellSnapshot {
|
|
932
|
+
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
933
|
+
const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
|
|
934
|
+
const mode = snapshot.mode;
|
|
935
|
+
const commonKeys = [
|
|
936
|
+
'schemaVersion',
|
|
937
|
+
'captain',
|
|
938
|
+
'issuedSessionIds',
|
|
939
|
+
'sequences',
|
|
940
|
+
'journal',
|
|
941
|
+
'lastAction',
|
|
942
|
+
'lastSettlementStatus',
|
|
943
|
+
'mode',
|
|
944
|
+
];
|
|
945
|
+
if (mode === 'chat') {
|
|
946
|
+
rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
|
|
947
|
+
} else if (mode === 'engaged.parked') {
|
|
948
|
+
rejectSnapshotKeys(
|
|
949
|
+
snapshot,
|
|
950
|
+
[
|
|
951
|
+
...commonKeys,
|
|
952
|
+
'frames',
|
|
953
|
+
'rootPlayerResumeTokens',
|
|
954
|
+
'pendingBossQuestions',
|
|
955
|
+
'lastError',
|
|
956
|
+
],
|
|
957
|
+
'Captain shell snapshot',
|
|
958
|
+
);
|
|
959
|
+
} else {
|
|
960
|
+
throw new TypeError(
|
|
961
|
+
'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
if (snapshot.schemaVersion !== 1) {
|
|
965
|
+
throw new TypeError(
|
|
966
|
+
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const captain = snapshotRecord(
|
|
971
|
+
snapshot.captain,
|
|
972
|
+
'Captain shell snapshot.captain',
|
|
973
|
+
);
|
|
974
|
+
rejectSnapshotKeys(
|
|
975
|
+
captain,
|
|
976
|
+
['sessionId', 'runtime', 'conversation'],
|
|
977
|
+
'Captain shell snapshot.captain',
|
|
978
|
+
);
|
|
979
|
+
const captainSessionId = snapshotUuid(
|
|
980
|
+
captain.sessionId,
|
|
981
|
+
'Captain shell snapshot.captain.sessionId',
|
|
982
|
+
);
|
|
983
|
+
const captainRuntime = assertPlaybookRuntimeSnapshot(
|
|
984
|
+
captain.runtime,
|
|
985
|
+
INTERNAL_CAPTAIN_ID,
|
|
986
|
+
);
|
|
987
|
+
const conversation = snapshotRecord(
|
|
988
|
+
captain.conversation,
|
|
989
|
+
'Captain shell snapshot.captain.conversation',
|
|
990
|
+
);
|
|
991
|
+
let normalizedConversation: PlaybookCaptainConversationSnapshot;
|
|
992
|
+
if (conversation.kind === 'pinned') {
|
|
993
|
+
rejectSnapshotKeys(
|
|
994
|
+
conversation,
|
|
995
|
+
['kind', 'token'],
|
|
996
|
+
'Captain shell snapshot.captain.conversation',
|
|
997
|
+
);
|
|
998
|
+
normalizedConversation = {
|
|
999
|
+
kind: 'pinned',
|
|
1000
|
+
token: snapshotString(
|
|
1001
|
+
conversation.token,
|
|
1002
|
+
'Captain shell snapshot.captain.conversation.token',
|
|
1003
|
+
true,
|
|
1004
|
+
),
|
|
1005
|
+
};
|
|
1006
|
+
} else if (
|
|
1007
|
+
conversation.kind === 'unopened' ||
|
|
1008
|
+
conversation.kind === 'needsSeeding'
|
|
1009
|
+
) {
|
|
1010
|
+
rejectSnapshotKeys(
|
|
1011
|
+
conversation,
|
|
1012
|
+
['kind'],
|
|
1013
|
+
'Captain shell snapshot.captain.conversation',
|
|
1014
|
+
);
|
|
1015
|
+
normalizedConversation = { kind: conversation.kind };
|
|
1016
|
+
} else {
|
|
1017
|
+
throw new TypeError(
|
|
1018
|
+
'Captain shell snapshot.captain.conversation.kind is not supported',
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (!Array.isArray(snapshot.issuedSessionIds)) {
|
|
1023
|
+
throw new TypeError(
|
|
1024
|
+
'Captain shell snapshot.issuedSessionIds must be an array',
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
const issued = snapshot.issuedSessionIds.map((id, index) =>
|
|
1028
|
+
snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`),
|
|
1029
|
+
);
|
|
1030
|
+
if (new Set(issued).size !== issued.length) {
|
|
1031
|
+
throw new TypeError(
|
|
1032
|
+
'Captain shell snapshot.issuedSessionIds must not contain duplicates',
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
if (issued[0] !== captainSessionId) {
|
|
1036
|
+
throw new TypeError(
|
|
1037
|
+
'Captain shell snapshot Captain session id must be the first issued id',
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const sequences = snapshotRecord(
|
|
1042
|
+
snapshot.sequences,
|
|
1043
|
+
'Captain shell snapshot.sequences',
|
|
1044
|
+
);
|
|
1045
|
+
rejectSnapshotKeys(
|
|
1046
|
+
sequences,
|
|
1047
|
+
['turn', 'journal'],
|
|
1048
|
+
'Captain shell snapshot.sequences',
|
|
1049
|
+
);
|
|
1050
|
+
const turnSequence = snapshotInteger(
|
|
1051
|
+
sequences.turn,
|
|
1052
|
+
'Captain shell snapshot.sequences.turn',
|
|
1053
|
+
);
|
|
1054
|
+
const journalSequence = snapshotInteger(
|
|
1055
|
+
sequences.journal,
|
|
1056
|
+
'Captain shell snapshot.sequences.journal',
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
if (!Array.isArray(snapshot.journal)) {
|
|
1060
|
+
throw new TypeError('Captain shell snapshot.journal must be an array');
|
|
1061
|
+
}
|
|
1062
|
+
const normalizedJournal: PlaybookCaptainJournalRecord[] = [];
|
|
1063
|
+
let previousTurn = 0;
|
|
1064
|
+
let bossRecords = 0;
|
|
1065
|
+
for (const [index, value] of snapshot.journal.entries()) {
|
|
1066
|
+
const record = snapshotRecord(
|
|
1067
|
+
value,
|
|
1068
|
+
`Captain shell snapshot.journal[${index}]`,
|
|
1069
|
+
);
|
|
1070
|
+
rejectSnapshotKeys(
|
|
1071
|
+
record,
|
|
1072
|
+
['seq', 'turnId', 'kind', 'payload'],
|
|
1073
|
+
`Captain shell snapshot.journal[${index}]`,
|
|
1074
|
+
);
|
|
1075
|
+
const seq = snapshotInteger(
|
|
1076
|
+
record.seq,
|
|
1077
|
+
`Captain shell snapshot.journal[${index}].seq`,
|
|
1078
|
+
1,
|
|
1079
|
+
);
|
|
1080
|
+
if (seq !== index + 1) {
|
|
1081
|
+
throw new TypeError(
|
|
1082
|
+
'Captain shell snapshot journal sequence must be contiguous from one',
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
const turnId = snapshotInteger(
|
|
1086
|
+
record.turnId,
|
|
1087
|
+
`Captain shell snapshot.journal[${index}].turnId`,
|
|
1088
|
+
1,
|
|
1089
|
+
);
|
|
1090
|
+
if (turnId < previousTurn || turnId > turnSequence) {
|
|
1091
|
+
throw new TypeError(
|
|
1092
|
+
'Captain shell snapshot journal turn ids must be ordered and in range',
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
const kind = record.kind;
|
|
1096
|
+
if (
|
|
1097
|
+
typeof kind !== 'string' ||
|
|
1098
|
+
!SNAPSHOT_JOURNAL_KINDS.has(
|
|
1099
|
+
kind as PlaybookCaptainJournalRecord['kind'],
|
|
1100
|
+
)
|
|
1101
|
+
) {
|
|
1102
|
+
throw new TypeError(
|
|
1103
|
+
`Captain shell snapshot.journal[${index}].kind is not supported`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
if (turnId !== previousTurn) {
|
|
1107
|
+
if (turnId !== previousTurn + 1 || kind !== 'boss') {
|
|
1108
|
+
throw new TypeError(
|
|
1109
|
+
'Captain shell snapshot journal must begin every turn with one boss record',
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
bossRecords++;
|
|
1113
|
+
previousTurn = turnId;
|
|
1114
|
+
} else if (kind === 'boss') {
|
|
1115
|
+
throw new TypeError(
|
|
1116
|
+
'Captain shell snapshot journal must contain one boss record per turn',
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
normalizedJournal.push({
|
|
1120
|
+
seq,
|
|
1121
|
+
turnId,
|
|
1122
|
+
kind: kind as PlaybookCaptainJournalRecord['kind'],
|
|
1123
|
+
payload: record.payload as JsonValue,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
if (
|
|
1127
|
+
journalSequence !== normalizedJournal.length ||
|
|
1128
|
+
bossRecords !== turnSequence
|
|
1129
|
+
) {
|
|
1130
|
+
throw new TypeError(
|
|
1131
|
+
'Captain shell snapshot sequences do not match the complete journal',
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
|
|
1136
|
+
if (snapshot.lastAction !== undefined) {
|
|
1137
|
+
if (
|
|
1138
|
+
typeof snapshot.lastAction !== 'string' ||
|
|
1139
|
+
!SNAPSHOT_ACTIONS.has(
|
|
1140
|
+
snapshot.lastAction as NonNullable<typeof lastAction>,
|
|
1141
|
+
)
|
|
1142
|
+
) {
|
|
1143
|
+
throw new TypeError('Captain shell snapshot.lastAction is not supported');
|
|
1144
|
+
}
|
|
1145
|
+
lastAction = snapshot.lastAction as NonNullable<typeof lastAction>;
|
|
1146
|
+
}
|
|
1147
|
+
let lastSettlementStatus: PlaybookCaptainShellSnapshotFields['lastSettlementStatus'];
|
|
1148
|
+
if (snapshot.lastSettlementStatus !== undefined) {
|
|
1149
|
+
if (
|
|
1150
|
+
typeof snapshot.lastSettlementStatus !== 'string' ||
|
|
1151
|
+
!SNAPSHOT_SETTLEMENT_STATUSES.has(
|
|
1152
|
+
snapshot.lastSettlementStatus as NonNullable<
|
|
1153
|
+
typeof lastSettlementStatus
|
|
1154
|
+
>,
|
|
1155
|
+
)
|
|
1156
|
+
) {
|
|
1157
|
+
throw new TypeError(
|
|
1158
|
+
'Captain shell snapshot.lastSettlementStatus is not supported',
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
lastSettlementStatus = snapshot.lastSettlementStatus as NonNullable<
|
|
1162
|
+
typeof lastSettlementStatus
|
|
1163
|
+
>;
|
|
1164
|
+
}
|
|
1165
|
+
const common: PlaybookCaptainShellSnapshotFields = {
|
|
1166
|
+
schemaVersion: 1,
|
|
1167
|
+
captain: {
|
|
1168
|
+
sessionId: captainSessionId,
|
|
1169
|
+
runtime: captainRuntime,
|
|
1170
|
+
conversation: normalizedConversation,
|
|
1171
|
+
},
|
|
1172
|
+
issuedSessionIds: issued,
|
|
1173
|
+
sequences: { turn: turnSequence, journal: journalSequence },
|
|
1174
|
+
journal: normalizedJournal,
|
|
1175
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
1176
|
+
...(lastSettlementStatus === undefined
|
|
1177
|
+
? {}
|
|
1178
|
+
: { lastSettlementStatus }),
|
|
1179
|
+
};
|
|
1180
|
+
if (mode === 'chat') {
|
|
1181
|
+
return snapshotJsonValue(
|
|
1182
|
+
{ ...common, mode },
|
|
1183
|
+
'Captain shell snapshot',
|
|
1184
|
+
) as unknown as PlaybookCaptainShellSnapshot;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
|
|
1188
|
+
throw new TypeError(
|
|
1189
|
+
'Captain shell snapshot.frames must be a non-empty array',
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
const normalizedFrames: PlaybookCaptainFrameSnapshot[] = [];
|
|
1193
|
+
for (const [index, value] of snapshot.frames.entries()) {
|
|
1194
|
+
const frame = snapshotRecord(
|
|
1195
|
+
value,
|
|
1196
|
+
`Captain shell snapshot.frames[${index}]`,
|
|
1197
|
+
);
|
|
1198
|
+
rejectSnapshotKeys(
|
|
1199
|
+
frame,
|
|
1200
|
+
[
|
|
1201
|
+
'playbookId',
|
|
1202
|
+
'sessionId',
|
|
1203
|
+
'rootSessionId',
|
|
1204
|
+
'depth',
|
|
1205
|
+
'parentSessionId',
|
|
1206
|
+
'parentCallId',
|
|
1207
|
+
'runtime',
|
|
1208
|
+
],
|
|
1209
|
+
`Captain shell snapshot.frames[${index}]`,
|
|
1210
|
+
);
|
|
1211
|
+
const playbookId = snapshotString(
|
|
1212
|
+
frame.playbookId,
|
|
1213
|
+
`Captain shell snapshot.frames[${index}].playbookId`,
|
|
1214
|
+
);
|
|
1215
|
+
const sessionId = snapshotUuid(
|
|
1216
|
+
frame.sessionId,
|
|
1217
|
+
`Captain shell snapshot.frames[${index}].sessionId`,
|
|
1218
|
+
);
|
|
1219
|
+
const rootSessionId = snapshotUuid(
|
|
1220
|
+
frame.rootSessionId,
|
|
1221
|
+
`Captain shell snapshot.frames[${index}].rootSessionId`,
|
|
1222
|
+
);
|
|
1223
|
+
const depth = snapshotInteger(
|
|
1224
|
+
frame.depth,
|
|
1225
|
+
`Captain shell snapshot.frames[${index}].depth`,
|
|
1226
|
+
);
|
|
1227
|
+
const parentSessionId =
|
|
1228
|
+
frame.parentSessionId === undefined
|
|
1229
|
+
? undefined
|
|
1230
|
+
: snapshotUuid(
|
|
1231
|
+
frame.parentSessionId,
|
|
1232
|
+
`Captain shell snapshot.frames[${index}].parentSessionId`,
|
|
1233
|
+
);
|
|
1234
|
+
const parentCallId =
|
|
1235
|
+
frame.parentCallId === undefined
|
|
1236
|
+
? undefined
|
|
1237
|
+
: snapshotString(
|
|
1238
|
+
frame.parentCallId,
|
|
1239
|
+
`Captain shell snapshot.frames[${index}].parentCallId`,
|
|
1240
|
+
);
|
|
1241
|
+
const runtime = assertPlaybookRuntimeSnapshot(
|
|
1242
|
+
frame.runtime,
|
|
1243
|
+
playbookId,
|
|
1244
|
+
{ allowSuspendedCall: true },
|
|
1245
|
+
);
|
|
1246
|
+
normalizedFrames.push({
|
|
1247
|
+
playbookId,
|
|
1248
|
+
sessionId,
|
|
1249
|
+
rootSessionId,
|
|
1250
|
+
depth,
|
|
1251
|
+
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
1252
|
+
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
1253
|
+
runtime,
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const rootTokens = snapshotRecord(
|
|
1258
|
+
snapshot.rootPlayerResumeTokens,
|
|
1259
|
+
'Captain shell snapshot.rootPlayerResumeTokens',
|
|
1260
|
+
);
|
|
1261
|
+
const normalizedRootTokens = Object.fromEntries(
|
|
1262
|
+
Object.entries(rootTokens).map(([playerId, token]) => [
|
|
1263
|
+
playerId,
|
|
1264
|
+
snapshotString(
|
|
1265
|
+
token,
|
|
1266
|
+
`Captain shell snapshot.rootPlayerResumeTokens.${playerId}`,
|
|
1267
|
+
),
|
|
1268
|
+
]),
|
|
1269
|
+
);
|
|
1270
|
+
let normalizedLastError:
|
|
1271
|
+
| { readonly name: string; readonly message: string }
|
|
1272
|
+
| undefined;
|
|
1273
|
+
if (snapshot.lastError !== undefined) {
|
|
1274
|
+
const error = snapshotRecord(
|
|
1275
|
+
snapshot.lastError,
|
|
1276
|
+
'Captain shell snapshot.lastError',
|
|
1277
|
+
);
|
|
1278
|
+
rejectSnapshotKeys(
|
|
1279
|
+
error,
|
|
1280
|
+
['name', 'message'],
|
|
1281
|
+
'Captain shell snapshot.lastError',
|
|
1282
|
+
);
|
|
1283
|
+
normalizedLastError = {
|
|
1284
|
+
name: snapshotString(
|
|
1285
|
+
error.name,
|
|
1286
|
+
'Captain shell snapshot.lastError.name',
|
|
1287
|
+
true,
|
|
1288
|
+
),
|
|
1289
|
+
message: snapshotString(
|
|
1290
|
+
error.message,
|
|
1291
|
+
'Captain shell snapshot.lastError.message',
|
|
1292
|
+
true,
|
|
1293
|
+
),
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
return snapshotJsonValue(
|
|
1297
|
+
{
|
|
1298
|
+
...common,
|
|
1299
|
+
mode,
|
|
1300
|
+
frames: normalizedFrames,
|
|
1301
|
+
rootPlayerResumeTokens: normalizedRootTokens,
|
|
1302
|
+
...(snapshot.pendingBossQuestions === undefined
|
|
1303
|
+
? {}
|
|
1304
|
+
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
1305
|
+
...(normalizedLastError === undefined
|
|
1306
|
+
? {}
|
|
1307
|
+
: { lastError: normalizedLastError }),
|
|
1308
|
+
},
|
|
1309
|
+
'Captain shell snapshot',
|
|
1310
|
+
) as unknown as PlaybookCaptainShellSnapshot;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
768
1313
|
function readPlaybooksConfig(
|
|
769
1314
|
options: unknown,
|
|
770
1315
|
): Record<string, unknown> | undefined {
|
|
@@ -889,7 +1434,7 @@ async function buildEnablements(
|
|
|
889
1434
|
export function createPlaybookCaptainShell(
|
|
890
1435
|
options: unknown,
|
|
891
1436
|
deps: PlaybookCaptainDeps = {},
|
|
892
|
-
):
|
|
1437
|
+
): PlaybookCaptainShell {
|
|
893
1438
|
const loadModule =
|
|
894
1439
|
deps.loadModule ?? ((specifier: string) => import(specifier));
|
|
895
1440
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
@@ -905,6 +1450,16 @@ export function createPlaybookCaptainShell(
|
|
|
905
1450
|
let byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
906
1451
|
let enablementById = new Map<string, Enablement>();
|
|
907
1452
|
let session: CaptainSession | undefined;
|
|
1453
|
+
let sessionEmissionsOpen = false;
|
|
1454
|
+
let closedGateAttempted = false;
|
|
1455
|
+
let lifecycle:
|
|
1456
|
+
| 'fresh'
|
|
1457
|
+
| 'initializing'
|
|
1458
|
+
| 'restoring'
|
|
1459
|
+
| 'ready'
|
|
1460
|
+
| 'disposing'
|
|
1461
|
+
| 'closed' = 'fresh';
|
|
1462
|
+
let terminallyDisposed = false;
|
|
908
1463
|
let players: readonly RegistryPlayer[] = [];
|
|
909
1464
|
let activeContext: CaptainContext | undefined;
|
|
910
1465
|
const frames: EngagementFrame[] = [];
|
|
@@ -918,6 +1473,42 @@ export function createPlaybookCaptainShell(
|
|
|
918
1473
|
const captainQueue = new PQueue({ concurrency: 1 });
|
|
919
1474
|
let disposing = false;
|
|
920
1475
|
|
|
1476
|
+
const admitHostBoundary = (): void => {
|
|
1477
|
+
if (sessionEmissionsOpen) return;
|
|
1478
|
+
closedGateAttempted = true;
|
|
1479
|
+
throw new Error('Captain shell host boundaries are closed during restore');
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
const admitHostEmission = (): boolean => {
|
|
1483
|
+
if (sessionEmissionsOpen) return true;
|
|
1484
|
+
closedGateAttempted = true;
|
|
1485
|
+
return false;
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
const installSession = (
|
|
1489
|
+
initSession: CaptainSession,
|
|
1490
|
+
emissionsOpen: boolean,
|
|
1491
|
+
): void => {
|
|
1492
|
+
sessionEmissionsOpen = emissionsOpen;
|
|
1493
|
+
closedGateAttempted = false;
|
|
1494
|
+
session = {
|
|
1495
|
+
signal: initSession.signal,
|
|
1496
|
+
players: initSession.players,
|
|
1497
|
+
emitStatus: async (message, data) => {
|
|
1498
|
+
if (!admitHostEmission()) return;
|
|
1499
|
+
await initSession.emitStatus(message, data);
|
|
1500
|
+
},
|
|
1501
|
+
emitTelemetry: async (event) => {
|
|
1502
|
+
if (!admitHostEmission()) return;
|
|
1503
|
+
await initSession.emitTelemetry(event);
|
|
1504
|
+
},
|
|
1505
|
+
setVisiblePlayers: async (playerIds) => {
|
|
1506
|
+
if (!admitHostEmission()) return;
|
|
1507
|
+
await initSession.setVisiblePlayers(playerIds);
|
|
1508
|
+
},
|
|
1509
|
+
};
|
|
1510
|
+
};
|
|
1511
|
+
|
|
921
1512
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
922
1513
|
let captainRuntime: PlaybookRuntime | undefined;
|
|
923
1514
|
let captainSessionId: string | undefined;
|
|
@@ -1236,6 +1827,7 @@ export function createPlaybookCaptainShell(
|
|
|
1236
1827
|
|
|
1237
1828
|
const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
|
|
1238
1829
|
callPlayer: async (playerId, prompt, signal, options) => {
|
|
1830
|
+
admitHostBoundary();
|
|
1239
1831
|
if (!activeContext) {
|
|
1240
1832
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
1241
1833
|
}
|
|
@@ -1272,6 +1864,7 @@ export function createPlaybookCaptainShell(
|
|
|
1272
1864
|
};
|
|
1273
1865
|
},
|
|
1274
1866
|
callCaptain: async (prompt, signal, options) => {
|
|
1867
|
+
admitHostBoundary();
|
|
1275
1868
|
if (!activeContext) {
|
|
1276
1869
|
throw new Error('callCaptain invoked outside a Boss turn');
|
|
1277
1870
|
}
|
|
@@ -1295,6 +1888,7 @@ export function createPlaybookCaptainShell(
|
|
|
1295
1888
|
};
|
|
1296
1889
|
},
|
|
1297
1890
|
callJudge: async (prompt, signal) => {
|
|
1891
|
+
admitHostBoundary();
|
|
1298
1892
|
if (!activeContext) {
|
|
1299
1893
|
throw new Error('callJudge invoked outside a Boss turn');
|
|
1300
1894
|
}
|
|
@@ -1330,6 +1924,7 @@ export function createPlaybookCaptainShell(
|
|
|
1330
1924
|
return result.finalText;
|
|
1331
1925
|
},
|
|
1332
1926
|
callPlaybook: (request, signal) => {
|
|
1927
|
+
admitHostBoundary();
|
|
1333
1928
|
const opening = callNestedPlaybook(frame, request, signal);
|
|
1334
1929
|
let exposed!: Promise<PlaybookCallStart>;
|
|
1335
1930
|
const registerOpeningCleanup = (): void => {
|
|
@@ -1342,17 +1937,27 @@ export function createPlaybookCaptainShell(
|
|
|
1342
1937
|
if (signal.aborted) registerOpeningCleanup();
|
|
1343
1938
|
return exposed;
|
|
1344
1939
|
},
|
|
1345
|
-
emitStatus:
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1940
|
+
emitStatus: (message, data) => {
|
|
1941
|
+
if (!admitHostEmission()) return Promise.resolve();
|
|
1942
|
+
return trackHostCall(
|
|
1943
|
+
frame,
|
|
1944
|
+
(async (): Promise<void> => {
|
|
1945
|
+
await requireSession().emitStatus(
|
|
1946
|
+
message,
|
|
1947
|
+
data as Record<string, unknown> | undefined,
|
|
1948
|
+
);
|
|
1949
|
+
})(),
|
|
1349
1950
|
);
|
|
1350
1951
|
},
|
|
1351
|
-
emitTelemetry:
|
|
1352
|
-
if (
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1952
|
+
emitTelemetry: (event) => {
|
|
1953
|
+
if (!admitHostEmission()) return Promise.resolve();
|
|
1954
|
+
const emission = (async (): Promise<void> => {
|
|
1955
|
+
if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
|
|
1956
|
+
await mirrorSubRuntimeTelemetry(frame, event.payload);
|
|
1957
|
+
}
|
|
1958
|
+
await requireSession().emitTelemetry(event);
|
|
1959
|
+
})();
|
|
1960
|
+
return trackHostCall(frame, emission);
|
|
1356
1961
|
},
|
|
1357
1962
|
});
|
|
1358
1963
|
|
|
@@ -1402,12 +2007,11 @@ export function createPlaybookCaptainShell(
|
|
|
1402
2007
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
1403
2008
|
};
|
|
1404
2009
|
|
|
1405
|
-
const
|
|
2010
|
+
const makePlayerBindings = (
|
|
1406
2011
|
enablement: Enablement,
|
|
1407
2012
|
parent?: { frame: EngagementFrame; callId: string },
|
|
1408
|
-
):
|
|
2013
|
+
): ReadonlyMap<string, EffectivePlayerBinding> => {
|
|
1409
2014
|
const entry = enablement.entry;
|
|
1410
|
-
const sessionId = allocateSessionId();
|
|
1411
2015
|
const playerBindings = new Map<string, EffectivePlayerBinding>();
|
|
1412
2016
|
for (const role of entry.requiredRoleIds) {
|
|
1413
2017
|
let inherited: EffectivePlayerBinding | undefined;
|
|
@@ -1430,6 +2034,16 @@ export function createPlaybookCaptainShell(
|
|
|
1430
2034
|
player: configured,
|
|
1431
2035
|
});
|
|
1432
2036
|
}
|
|
2037
|
+
return playerBindings;
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
const makeFrame = (
|
|
2041
|
+
enablement: Enablement,
|
|
2042
|
+
parent?: { frame: EngagementFrame; callId: string },
|
|
2043
|
+
): EngagementFrame => {
|
|
2044
|
+
const entry = enablement.entry;
|
|
2045
|
+
const sessionId = allocateSessionId();
|
|
2046
|
+
const playerBindings = makePlayerBindings(enablement, parent);
|
|
1433
2047
|
const playerResumeTokens =
|
|
1434
2048
|
parent?.frame.playerResumeTokens ?? new Map<string, string>();
|
|
1435
2049
|
const runtime = entry.createRuntime({
|
|
@@ -1454,6 +2068,37 @@ export function createPlaybookCaptainShell(
|
|
|
1454
2068
|
};
|
|
1455
2069
|
};
|
|
1456
2070
|
|
|
2071
|
+
const makeRestoredFrame = (
|
|
2072
|
+
enablement: Enablement,
|
|
2073
|
+
snapshot: PlaybookCaptainFrameSnapshot,
|
|
2074
|
+
rootPlayerResumeTokens: Map<string, string>,
|
|
2075
|
+
parent?: { frame: EngagementFrame; callId: string },
|
|
2076
|
+
): EngagementFrame => {
|
|
2077
|
+
const entry = enablement.entry;
|
|
2078
|
+
const playerBindings = makePlayerBindings(enablement, parent);
|
|
2079
|
+
const runtime = entry.createRuntime({
|
|
2080
|
+
captainOptions: enablement.optionInput,
|
|
2081
|
+
players: [...playerBindings].map(([role, { player }]) => ({
|
|
2082
|
+
id: role,
|
|
2083
|
+
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
2084
|
+
...(player.model === undefined ? {} : { model: player.model }),
|
|
2085
|
+
})),
|
|
2086
|
+
});
|
|
2087
|
+
return {
|
|
2088
|
+
entry,
|
|
2089
|
+
enablement,
|
|
2090
|
+
runtime,
|
|
2091
|
+
sessionId: snapshot.sessionId,
|
|
2092
|
+
rootSessionId: snapshot.rootSessionId,
|
|
2093
|
+
depth: snapshot.depth,
|
|
2094
|
+
playerBindings,
|
|
2095
|
+
playerResumeTokens: rootPlayerResumeTokens,
|
|
2096
|
+
...(parent ? { parent } : {}),
|
|
2097
|
+
state: snapshot.runtime.state,
|
|
2098
|
+
inFlightHostCalls: new Set(),
|
|
2099
|
+
};
|
|
2100
|
+
};
|
|
2101
|
+
|
|
1457
2102
|
const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
|
|
1458
2103
|
select(playerId) {
|
|
1459
2104
|
const binding = bindingFor(frame, playerId);
|
|
@@ -1486,8 +2131,7 @@ export function createPlaybookCaptainShell(
|
|
|
1486
2131
|
},
|
|
1487
2132
|
});
|
|
1488
2133
|
|
|
1489
|
-
const
|
|
1490
|
-
await frame.runtime.init({
|
|
2134
|
+
const frameSession = (frame: EngagementFrame) => ({
|
|
1491
2135
|
sessionId: frame.sessionId,
|
|
1492
2136
|
playbookId: frame.entry.id,
|
|
1493
2137
|
rootSessionId: frame.rootSessionId,
|
|
@@ -1501,6 +2145,9 @@ export function createPlaybookCaptainShell(
|
|
|
1501
2145
|
playerSessions: playerSessionStore(frame),
|
|
1502
2146
|
ports: createPorts(frame),
|
|
1503
2147
|
});
|
|
2148
|
+
|
|
2149
|
+
const initFrame = async (frame: EngagementFrame): Promise<void> => {
|
|
2150
|
+
await frame.runtime.init(frameSession(frame));
|
|
1504
2151
|
};
|
|
1505
2152
|
|
|
1506
2153
|
const clearLeafLedger = (): void => {
|
|
@@ -1920,6 +2567,12 @@ export function createPlaybookCaptainShell(
|
|
|
1920
2567
|
if (frame.parent) {
|
|
1921
2568
|
await resumeParent(frame, callResultFor(frame, result), context);
|
|
1922
2569
|
} else {
|
|
2570
|
+
// CAPTAIN-20: the root is still alive here, so this is the one
|
|
2571
|
+
// authoritative boundary that can retain the Boss-facing meaning its
|
|
2572
|
+
// runtime publishes before disposal removes the frame. The opaque run
|
|
2573
|
+
// output remains runtime-to-runtime data and never becomes Captain
|
|
2574
|
+
// evidence (CAPPLAY-10).
|
|
2575
|
+
activeTurn?.settlementFacts.push(rootCompletionFact(frame));
|
|
1923
2576
|
await runEffect(() => disposeStack('final'));
|
|
1924
2577
|
}
|
|
1925
2578
|
return;
|
|
@@ -2751,9 +3404,11 @@ export function createPlaybookCaptainShell(
|
|
|
2751
3404
|
|
|
2752
3405
|
const captainPorts = (): PlaybookPorts => ({
|
|
2753
3406
|
callPlayer: async () => {
|
|
3407
|
+
admitHostBoundary();
|
|
2754
3408
|
throw new Error('the session Captain has no players');
|
|
2755
3409
|
},
|
|
2756
3410
|
callCaptain: async (prompt, signal) => {
|
|
3411
|
+
admitHostBoundary();
|
|
2757
3412
|
if (!activeContext) {
|
|
2758
3413
|
throw new Error('the session Captain called out of a Boss turn');
|
|
2759
3414
|
}
|
|
@@ -2821,29 +3476,37 @@ export function createPlaybookCaptainShell(
|
|
|
2821
3476
|
return { status: 'ok' as const, finalText: 'ok' };
|
|
2822
3477
|
},
|
|
2823
3478
|
callJudge: async () => {
|
|
3479
|
+
admitHostBoundary();
|
|
2824
3480
|
throw new Error('the session Captain makes no judge call');
|
|
2825
3481
|
},
|
|
2826
3482
|
callPlaybook: async () => {
|
|
3483
|
+
admitHostBoundary();
|
|
2827
3484
|
throw new Error('the session Captain never calls a playbook');
|
|
2828
3485
|
},
|
|
2829
3486
|
// CAPTAIN-9: the session Captain's human status stream is suppressed while
|
|
2830
3487
|
// its structured telemetry is forwarded.
|
|
2831
|
-
emitStatus: async () => {
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
3488
|
+
emitStatus: async () => {
|
|
3489
|
+
admitHostEmission();
|
|
3490
|
+
},
|
|
3491
|
+
emitTelemetry: (event) => {
|
|
3492
|
+
if (!admitHostEmission()) return Promise.resolve();
|
|
3493
|
+
const emission = (async (): Promise<void> => {
|
|
3494
|
+
if (event.topic === 'playbook.trace') {
|
|
3495
|
+
const payload = payloadRecord(event.payload);
|
|
3496
|
+
if (payload?.type === 'captain.call.started') {
|
|
3497
|
+
const identity = payloadRecord(payload.payload);
|
|
3498
|
+
const stateId = identity?.stateId;
|
|
3499
|
+
servingCall =
|
|
3500
|
+
stateId === 'reporting'
|
|
3501
|
+
? 'closingReply'
|
|
3502
|
+
: stateId === 'answeringCommand'
|
|
3503
|
+
? 'commandReply'
|
|
3504
|
+
: 'decision';
|
|
3505
|
+
}
|
|
2844
3506
|
}
|
|
2845
|
-
|
|
2846
|
-
|
|
3507
|
+
await requireSession().emitTelemetry(event);
|
|
3508
|
+
})();
|
|
3509
|
+
return trackTurnCall(emission);
|
|
2847
3510
|
},
|
|
2848
3511
|
});
|
|
2849
3512
|
|
|
@@ -2922,6 +3585,15 @@ export function createPlaybookCaptainShell(
|
|
|
2922
3585
|
}
|
|
2923
3586
|
};
|
|
2924
3587
|
|
|
3588
|
+
const rootCompletionFact = (frame: EngagementFrame): string => {
|
|
3589
|
+
const published = leafStateDescription(frame);
|
|
3590
|
+
const description =
|
|
3591
|
+
published === undefined ? '' : compactEvidence(published);
|
|
3592
|
+
return description === ''
|
|
3593
|
+
? `${frameLabel(frame)} completed; its runtime published no result description.`
|
|
3594
|
+
: `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
|
|
3595
|
+
};
|
|
3596
|
+
|
|
2925
3597
|
const leafStateSummary = (): string | undefined => {
|
|
2926
3598
|
const leaf = leafFrame();
|
|
2927
3599
|
if (!leaf) return 'idle: no playbook is engaged';
|
|
@@ -3404,9 +4076,6 @@ export function createPlaybookCaptainShell(
|
|
|
3404
4076
|
};
|
|
3405
4077
|
throw outcome.error;
|
|
3406
4078
|
}
|
|
3407
|
-
if (!frames.includes(leaf)) {
|
|
3408
|
-
facts.push(`${frameLabel(leaf)} finished and was disposed.`);
|
|
3409
|
-
}
|
|
3410
4079
|
const runFailed = drainRunFailureFacts(facts);
|
|
3411
4080
|
const summary = leafStateSummary();
|
|
3412
4081
|
turn.report = {
|
|
@@ -3581,9 +4250,14 @@ export function createPlaybookCaptainShell(
|
|
|
3581
4250
|
};
|
|
3582
4251
|
|
|
3583
4252
|
const controller: CaptainControllerPort = {
|
|
3584
|
-
submit: (selection, signal) =>
|
|
3585
|
-
|
|
3586
|
-
|
|
4253
|
+
submit: (selection, signal) => {
|
|
4254
|
+
admitHostBoundary();
|
|
4255
|
+
return settleSelection(selection, signal);
|
|
4256
|
+
},
|
|
4257
|
+
resolveParsedTurn: () => {
|
|
4258
|
+
admitHostBoundary();
|
|
4259
|
+
return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
|
|
4260
|
+
},
|
|
3587
4261
|
};
|
|
3588
4262
|
|
|
3589
4263
|
// -------------------------------------------------------------------------
|
|
@@ -3661,48 +4335,597 @@ export function createPlaybookCaptainShell(
|
|
|
3661
4335
|
});
|
|
3662
4336
|
};
|
|
3663
4337
|
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
4338
|
+
const enabledCatalog = () =>
|
|
4339
|
+
Object.freeze(
|
|
4340
|
+
entries.map((entry) =>
|
|
4341
|
+
Object.freeze({
|
|
4342
|
+
id: entry.id,
|
|
4343
|
+
command: enablementById.get(entry.id)!.command,
|
|
4344
|
+
intent: entry.intent,
|
|
4345
|
+
}),
|
|
4346
|
+
),
|
|
4347
|
+
);
|
|
4348
|
+
|
|
4349
|
+
const captainPlaybookSession = (
|
|
4350
|
+
id: string,
|
|
4351
|
+
) => ({
|
|
4352
|
+
sessionId: id,
|
|
4353
|
+
playbookId: INTERNAL_CAPTAIN_ID,
|
|
4354
|
+
rootSessionId: id,
|
|
4355
|
+
depth: 0,
|
|
4356
|
+
ports: captainPorts(),
|
|
4357
|
+
});
|
|
4358
|
+
|
|
4359
|
+
const tokenRecord = (
|
|
4360
|
+
tokens: ReadonlyMap<string, string>,
|
|
4361
|
+
): Readonly<Record<string, string>> => Object.fromEntries(tokens);
|
|
4362
|
+
|
|
4363
|
+
const assertSnapshotMatchesEnablements = (
|
|
4364
|
+
snapshot: PlaybookCaptainShellSnapshot,
|
|
4365
|
+
enabled: ReadonlyMap<string, Enablement>,
|
|
4366
|
+
): void => {
|
|
4367
|
+
const captain = snapshot.captain.runtime;
|
|
4368
|
+
if (
|
|
4369
|
+
captain.schemaVersion !== 2 ||
|
|
4370
|
+
captain.state.status !== 'active' ||
|
|
4371
|
+
!captain.state.quiescent ||
|
|
4372
|
+
!captain.state.tags.includes('playbook.parked') ||
|
|
4373
|
+
captain.suspendedCall !== undefined ||
|
|
4374
|
+
Object.keys(captain.playerResumeTokens).length > 0 ||
|
|
4375
|
+
captain.pendingBossQuestions.length > 0
|
|
4376
|
+
) {
|
|
4377
|
+
throw new TypeError(
|
|
4378
|
+
'Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended',
|
|
4379
|
+
);
|
|
4380
|
+
}
|
|
4381
|
+
if (captain.sequences.turn !== snapshot.sequences.turn) {
|
|
4382
|
+
throw new TypeError(
|
|
4383
|
+
'Captain shell snapshot Captain and shell turn sequences must match',
|
|
4384
|
+
);
|
|
4385
|
+
}
|
|
4386
|
+
const emptyHistory =
|
|
4387
|
+
snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
|
|
4388
|
+
if (
|
|
4389
|
+
(snapshot.captain.conversation.kind === 'unopened') !== emptyHistory
|
|
4390
|
+
) {
|
|
4391
|
+
throw new TypeError(
|
|
4392
|
+
'Captain shell snapshot unopened conversation must exactly match an empty session history',
|
|
4393
|
+
);
|
|
4394
|
+
}
|
|
4395
|
+
if (snapshot.mode === 'chat') return;
|
|
4396
|
+
|
|
4397
|
+
const activePlaybooks = new Set<string>();
|
|
4398
|
+
const activeSessionIds = new Set<string>([snapshot.captain.sessionId]);
|
|
4399
|
+
const issuedIds = new Set(snapshot.issuedSessionIds);
|
|
4400
|
+
const allowedHostPlayerIds = new Set<string>();
|
|
4401
|
+
for (const enablement of enabled.values()) {
|
|
4402
|
+
for (const role of enablement.entry.requiredRoleIds) {
|
|
4403
|
+
allowedHostPlayerIds.add(enablement.hostPlayerId(role));
|
|
4404
|
+
}
|
|
4405
|
+
}
|
|
4406
|
+
for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
|
|
4407
|
+
if (!allowedHostPlayerIds.has(playerId)) {
|
|
4408
|
+
throw new TypeError(
|
|
4409
|
+
`Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`,
|
|
4410
|
+
);
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
const bindingMaps: Map<string, string>[] = [];
|
|
4415
|
+
const rootSessionId = snapshot.frames[0]!.sessionId;
|
|
4416
|
+
for (const [index, frame] of snapshot.frames.entries()) {
|
|
4417
|
+
const enablement = enabled.get(frame.playbookId);
|
|
4418
|
+
if (!enablement) {
|
|
4419
|
+
throw new TypeError(
|
|
4420
|
+
`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
|
|
4421
|
+
);
|
|
4422
|
+
}
|
|
4423
|
+
if (activePlaybooks.has(frame.playbookId)) {
|
|
4424
|
+
throw new TypeError(
|
|
4425
|
+
'Captain shell snapshot engagement path must not contain a playbook cycle',
|
|
4426
|
+
);
|
|
4427
|
+
}
|
|
4428
|
+
activePlaybooks.add(frame.playbookId);
|
|
4429
|
+
if (activeSessionIds.has(frame.sessionId)) {
|
|
4430
|
+
throw new TypeError(
|
|
4431
|
+
'Captain shell snapshot frame session ids must be unique',
|
|
4432
|
+
);
|
|
4433
|
+
}
|
|
4434
|
+
activeSessionIds.add(frame.sessionId);
|
|
4435
|
+
if (!issuedIds.has(frame.sessionId)) {
|
|
4436
|
+
throw new TypeError(
|
|
4437
|
+
'Captain shell snapshot frame session id was not historically issued',
|
|
4438
|
+
);
|
|
4439
|
+
}
|
|
4440
|
+
if (
|
|
4441
|
+
frame.depth !== index ||
|
|
4442
|
+
frame.rootSessionId !== rootSessionId ||
|
|
4443
|
+
frame.runtime.state.status !== 'active' ||
|
|
4444
|
+
!frame.runtime.state.quiescent
|
|
4445
|
+
) {
|
|
4446
|
+
throw new TypeError(
|
|
4447
|
+
'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
|
|
4448
|
+
);
|
|
4449
|
+
}
|
|
4450
|
+
if (index === 0) {
|
|
4451
|
+
if (
|
|
4452
|
+
frame.sessionId !== frame.rootSessionId ||
|
|
4453
|
+
frame.parentSessionId !== undefined ||
|
|
4454
|
+
frame.parentCallId !== undefined
|
|
4455
|
+
) {
|
|
4456
|
+
throw new TypeError(
|
|
4457
|
+
'Captain shell snapshot root frame has child-only identity fields',
|
|
4458
|
+
);
|
|
4459
|
+
}
|
|
4460
|
+
} else {
|
|
4461
|
+
const parent = snapshot.frames[index - 1]!;
|
|
4462
|
+
if (
|
|
4463
|
+
frame.parentSessionId !== parent.sessionId ||
|
|
4464
|
+
frame.parentCallId === undefined
|
|
4465
|
+
) {
|
|
4466
|
+
throw new TypeError(
|
|
4467
|
+
'Captain shell snapshot child frame does not identify its immediate parent',
|
|
4468
|
+
);
|
|
4469
|
+
}
|
|
4470
|
+
const pending = parent.runtime.suspendedCall;
|
|
4471
|
+
if (
|
|
4472
|
+
!pending ||
|
|
4473
|
+
pending.callId !== frame.parentCallId ||
|
|
4474
|
+
pending.playbookId !== frame.playbookId ||
|
|
4475
|
+
pending.childSessionId !== frame.sessionId
|
|
4476
|
+
) {
|
|
4477
|
+
throw new TypeError(
|
|
4478
|
+
'Captain shell snapshot parent suspended call does not match its child edge',
|
|
4479
|
+
);
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
const roleBindings = new Map<string, string>();
|
|
4484
|
+
for (const role of enablement.entry.requiredRoleIds) {
|
|
4485
|
+
let inherited: string | undefined;
|
|
4486
|
+
for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
|
|
4487
|
+
inherited = bindingMaps[ancestor]?.get(role);
|
|
4488
|
+
if (inherited !== undefined) break;
|
|
4489
|
+
}
|
|
4490
|
+
roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
|
|
4491
|
+
}
|
|
4492
|
+
bindingMaps.push(roleBindings);
|
|
4493
|
+
const projectedTokens = Object.fromEntries(
|
|
4494
|
+
[...roleBindings].flatMap(([role, hostPlayerId]) => {
|
|
4495
|
+
const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
|
|
4496
|
+
return token === undefined ? [] : [[role, token] as const];
|
|
4497
|
+
}),
|
|
4498
|
+
);
|
|
4499
|
+
if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
|
|
4500
|
+
throw new TypeError(
|
|
4501
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`,
|
|
4502
|
+
);
|
|
4503
|
+
}
|
|
4504
|
+
}
|
|
4505
|
+
const leafRuntime = snapshot.frames.at(-1)!.runtime;
|
|
4506
|
+
if (
|
|
4507
|
+
leafRuntime.suspendedCall !== undefined ||
|
|
4508
|
+
!leafRuntime.state.tags.includes('playbook.parked')
|
|
4509
|
+
) {
|
|
4510
|
+
throw new TypeError(
|
|
4511
|
+
'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
|
|
4512
|
+
);
|
|
4513
|
+
}
|
|
4514
|
+
};
|
|
4515
|
+
|
|
4516
|
+
const safeCapturePoint = (): boolean => {
|
|
4517
|
+
if (
|
|
4518
|
+
lifecycle !== 'ready' ||
|
|
4519
|
+
terminallyDisposed ||
|
|
4520
|
+
!sessionEmissionsOpen ||
|
|
4521
|
+
!session ||
|
|
4522
|
+
session.signal.aborted ||
|
|
4523
|
+
!captainRuntime ||
|
|
4524
|
+
disposing ||
|
|
4525
|
+
shuttingDown ||
|
|
4526
|
+
activeContext !== undefined ||
|
|
4527
|
+
activeTurn !== undefined ||
|
|
4528
|
+
activeTurnHostCalls !== undefined ||
|
|
4529
|
+
activeTurnSummary !== undefined ||
|
|
4530
|
+
runFailureFacts !== undefined ||
|
|
4531
|
+
servingCall !== undefined ||
|
|
4532
|
+
decisionCall !== undefined ||
|
|
4533
|
+
captainQueue.pending !== 0 ||
|
|
4534
|
+
captainQueue.size !== 0 ||
|
|
4535
|
+
(mode !== 'chat' && mode !== 'engaged.parked')
|
|
4536
|
+
) {
|
|
4537
|
+
return false;
|
|
4538
|
+
}
|
|
4539
|
+
if (
|
|
4540
|
+
(mode === 'chat' &&
|
|
4541
|
+
(frames.length !== 0 ||
|
|
4542
|
+
pendingChildParents.size !== 0 ||
|
|
4543
|
+
pendingBossQuestions !== undefined ||
|
|
4544
|
+
lastError !== undefined)) ||
|
|
4545
|
+
(mode === 'engaged.parked' && frames.length === 0)
|
|
4546
|
+
) {
|
|
4547
|
+
return false;
|
|
4548
|
+
}
|
|
4549
|
+
const expectedParents = new Set(frames.slice(0, -1));
|
|
4550
|
+
if (
|
|
4551
|
+
pendingChildParents.size !== expectedParents.size ||
|
|
4552
|
+
[...pendingChildParents].some((frame) => !expectedParents.has(frame))
|
|
4553
|
+
) {
|
|
4554
|
+
return false;
|
|
4555
|
+
}
|
|
4556
|
+
return frames.every((frame, index) => {
|
|
4557
|
+
const liveInvocation =
|
|
4558
|
+
frame.invocationSignal !== undefined || frame.abortListener !== undefined;
|
|
4559
|
+
return (
|
|
4560
|
+
frame.state !== undefined &&
|
|
4561
|
+
frame.state.status === 'active' &&
|
|
4562
|
+
frame.state.quiescent &&
|
|
4563
|
+
!frame.disposing &&
|
|
4564
|
+
frame.disposePromise === undefined &&
|
|
4565
|
+
frame.removal === undefined &&
|
|
4566
|
+
frame.inFlightHostCalls.size === 0 &&
|
|
4567
|
+
(index === 0
|
|
4568
|
+
? !liveInvocation
|
|
4569
|
+
: !liveInvocation ||
|
|
4570
|
+
(frame.invocationSignal !== undefined &&
|
|
4571
|
+
!frame.invocationSignal.aborted &&
|
|
4572
|
+
frame.abortListener !== undefined))
|
|
4573
|
+
);
|
|
4574
|
+
});
|
|
4575
|
+
};
|
|
4576
|
+
|
|
4577
|
+
const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
|
|
4578
|
+
if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
|
|
4579
|
+
return undefined;
|
|
4580
|
+
}
|
|
4581
|
+
try {
|
|
4582
|
+
if (
|
|
4583
|
+
typeof captainRuntime.exportSnapshot !== 'function' ||
|
|
4584
|
+
typeof captainRuntime.restore !== 'function'
|
|
4585
|
+
) {
|
|
4586
|
+
return undefined;
|
|
4587
|
+
}
|
|
4588
|
+
const captainSnapshot = captainRuntime.exportSnapshot();
|
|
4589
|
+
if (captainSnapshot === undefined) return undefined;
|
|
4590
|
+
const frameSnapshots: PlaybookCaptainFrameSnapshot[] = [];
|
|
4591
|
+
for (const frame of frames) {
|
|
4592
|
+
if (
|
|
4593
|
+
typeof frame.runtime.exportSnapshot !== 'function' ||
|
|
4594
|
+
typeof frame.runtime.restore !== 'function'
|
|
4595
|
+
) {
|
|
4596
|
+
return undefined;
|
|
4597
|
+
}
|
|
4598
|
+
const runtime = frame.runtime.exportSnapshot();
|
|
4599
|
+
if (
|
|
4600
|
+
runtime === undefined ||
|
|
4601
|
+
!isDeepStrictEqual(frame.state, runtime.state)
|
|
4602
|
+
) {
|
|
4603
|
+
return undefined;
|
|
4604
|
+
}
|
|
4605
|
+
frameSnapshots.push({
|
|
4606
|
+
playbookId: frame.entry.id,
|
|
4607
|
+
sessionId: frame.sessionId,
|
|
4608
|
+
rootSessionId: frame.rootSessionId,
|
|
4609
|
+
depth: frame.depth,
|
|
4610
|
+
...(frame.parent
|
|
4611
|
+
? {
|
|
4612
|
+
parentSessionId: frame.parent.frame.sessionId,
|
|
4613
|
+
parentCallId: frame.parent.callId,
|
|
4614
|
+
}
|
|
4615
|
+
: {}),
|
|
4616
|
+
runtime,
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
const common = {
|
|
4620
|
+
schemaVersion: 1 as const,
|
|
4621
|
+
captain: {
|
|
4622
|
+
sessionId: captainSessionId,
|
|
4623
|
+
runtime: captainSnapshot,
|
|
4624
|
+
conversation,
|
|
4625
|
+
},
|
|
4626
|
+
issuedSessionIds: [...issuedSessionIds],
|
|
4627
|
+
sequences: { turn: turnSequence, journal: journalSeq },
|
|
4628
|
+
journal,
|
|
4629
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
4630
|
+
...(lastSettlementStatus === undefined
|
|
4631
|
+
? {}
|
|
4632
|
+
: { lastSettlementStatus }),
|
|
4633
|
+
};
|
|
4634
|
+
const candidate: PlaybookCaptainShellSnapshot =
|
|
4635
|
+
mode === 'chat'
|
|
4636
|
+
? { ...common, mode }
|
|
4637
|
+
: {
|
|
4638
|
+
...common,
|
|
4639
|
+
mode: 'engaged.parked',
|
|
4640
|
+
frames: frameSnapshots,
|
|
4641
|
+
rootPlayerResumeTokens: tokenRecord(
|
|
4642
|
+
rootFrame()!.playerResumeTokens,
|
|
4643
|
+
),
|
|
4644
|
+
...(pendingBossQuestions === undefined
|
|
4645
|
+
? {}
|
|
4646
|
+
: { pendingBossQuestions: pendingBossQuestions as JsonValue }),
|
|
4647
|
+
...(lastError === undefined ? {} : { lastError }),
|
|
4648
|
+
};
|
|
4649
|
+
const normalized = assertPlaybookCaptainShellSnapshot(candidate);
|
|
4650
|
+
assertSnapshotMatchesEnablements(normalized, enablementById);
|
|
4651
|
+
return normalized;
|
|
4652
|
+
} catch {
|
|
4653
|
+
return undefined;
|
|
4654
|
+
}
|
|
4655
|
+
};
|
|
4656
|
+
|
|
4657
|
+
const verifyRestoredRuntime = (
|
|
4658
|
+
runtime: PlaybookRuntime,
|
|
4659
|
+
expected: PlaybookRuntimeSnapshot,
|
|
4660
|
+
playbookId: string,
|
|
4661
|
+
allowSuspendedCall: boolean,
|
|
4662
|
+
): void => {
|
|
4663
|
+
const actual = runtime.exportSnapshot?.();
|
|
4664
|
+
if (actual === undefined) {
|
|
4665
|
+
throw new Error(
|
|
4666
|
+
`restored ${playbookId} runtime did not reach a safe snapshot boundary`,
|
|
4667
|
+
);
|
|
4668
|
+
}
|
|
4669
|
+
const normalized = assertPlaybookRuntimeSnapshot(
|
|
4670
|
+
actual,
|
|
4671
|
+
playbookId,
|
|
4672
|
+
allowSuspendedCall ? { allowSuspendedCall: true } : {},
|
|
4673
|
+
);
|
|
4674
|
+
for (const key of [
|
|
4675
|
+
'state',
|
|
4676
|
+
'playerResumeTokens',
|
|
4677
|
+
'sequences',
|
|
4678
|
+
'pendingBossQuestions',
|
|
4679
|
+
'suspendedCall',
|
|
4680
|
+
] as const) {
|
|
4681
|
+
if (!isDeepStrictEqual(normalized[key], expected[key])) {
|
|
4682
|
+
throw new Error(
|
|
4683
|
+
`restored ${playbookId} runtime changed snapshot field ${key}`,
|
|
4684
|
+
);
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
};
|
|
4688
|
+
|
|
4689
|
+
const resetFailedRestore = async (): Promise<readonly unknown[]> => {
|
|
4690
|
+
const cleanupFailures: unknown[] = [];
|
|
4691
|
+
for (const frame of [...frames].reverse()) {
|
|
4692
|
+
frame.disposing = true;
|
|
4693
|
+
try {
|
|
4694
|
+
await frame.runtime.dispose();
|
|
4695
|
+
} catch (error) {
|
|
4696
|
+
cleanupFailures.push(error);
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
if (captainRuntime) {
|
|
4700
|
+
shuttingDown = true;
|
|
4701
|
+
try {
|
|
4702
|
+
await captainRuntime.dispose();
|
|
4703
|
+
} catch (error) {
|
|
4704
|
+
cleanupFailures.push(error);
|
|
4705
|
+
}
|
|
4706
|
+
}
|
|
4707
|
+
frames.splice(0);
|
|
4708
|
+
pendingChildParents.clear();
|
|
4709
|
+
issuedSessionIds.clear();
|
|
4710
|
+
journal.splice(0);
|
|
4711
|
+
entries = [];
|
|
4712
|
+
byCommand = new Map();
|
|
4713
|
+
byId = new Map();
|
|
4714
|
+
enablementById = new Map();
|
|
4715
|
+
players = [];
|
|
4716
|
+
session = undefined;
|
|
4717
|
+
sessionEmissionsOpen = false;
|
|
4718
|
+
closedGateAttempted = false;
|
|
4719
|
+
captainRuntime = undefined;
|
|
4720
|
+
captainSessionId = undefined;
|
|
4721
|
+
conversation = { kind: 'unopened' };
|
|
4722
|
+
mode = 'chat';
|
|
4723
|
+
pendingBossQuestions = undefined;
|
|
4724
|
+
lastError = undefined;
|
|
4725
|
+
journalSeq = 0;
|
|
4726
|
+
turnSequence = 0;
|
|
4727
|
+
lastAction = undefined;
|
|
4728
|
+
lastSettlementStatus = undefined;
|
|
4729
|
+
shuttingDown = false;
|
|
4730
|
+
if (cleanupFailures.length > 0) {
|
|
4731
|
+
terminallyDisposed = true;
|
|
4732
|
+
lifecycle = 'closed';
|
|
4733
|
+
} else {
|
|
4734
|
+
lifecycle = 'fresh';
|
|
4735
|
+
}
|
|
4736
|
+
return cleanupFailures;
|
|
4737
|
+
};
|
|
4738
|
+
|
|
4739
|
+
const restoreShellSnapshot = async (
|
|
4740
|
+
initSession: CaptainSession,
|
|
4741
|
+
untrusted: PlaybookCaptainShellSnapshot,
|
|
4742
|
+
): Promise<void> => {
|
|
4743
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
4744
|
+
throw new Error('Captain shell restore requires a fresh shell');
|
|
4745
|
+
}
|
|
4746
|
+
if (initSession.signal.aborted) {
|
|
4747
|
+
throw new Error('cannot restore an aborted Captain session');
|
|
4748
|
+
}
|
|
4749
|
+
lifecycle = 'restoring';
|
|
4750
|
+
try {
|
|
4751
|
+
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
4752
|
+
const built = await buildEnablements(
|
|
4753
|
+
options,
|
|
4754
|
+
initSession.players,
|
|
4755
|
+
loadModule,
|
|
4756
|
+
);
|
|
4757
|
+
for (const enablement of built.enablementById.values()) {
|
|
4758
|
+
enablement.entry.validateOptions(enablement.optionInput);
|
|
4759
|
+
}
|
|
4760
|
+
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
4761
|
+
|
|
4762
|
+
installSession(initSession, false);
|
|
3667
4763
|
players = initSession.players;
|
|
3668
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
3669
4764
|
entries = built.entries;
|
|
3670
4765
|
byCommand = built.byCommand;
|
|
3671
4766
|
byId = built.byId;
|
|
3672
4767
|
enablementById = built.enablementById;
|
|
3673
|
-
|
|
3674
|
-
enablement.entry.validateOptions(enablement.optionInput);
|
|
3675
|
-
}
|
|
3676
|
-
await setMode('chat', 'init');
|
|
3677
|
-
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
3678
|
-
// engagement stack, with its own playbook session id.
|
|
3679
|
-
const catalog = Object.freeze(
|
|
3680
|
-
entries.map((entry) =>
|
|
3681
|
-
Object.freeze({
|
|
3682
|
-
id: entry.id,
|
|
3683
|
-
command: enablementById.get(entry.id)!.command,
|
|
3684
|
-
intent: entry.intent,
|
|
3685
|
-
}),
|
|
3686
|
-
),
|
|
3687
|
-
);
|
|
3688
|
-
captainSessionId = allocateSessionId();
|
|
4768
|
+
|
|
3689
4769
|
captainRuntime = createCaptainRuntime({
|
|
3690
|
-
enabledPlaybooks:
|
|
4770
|
+
enabledPlaybooks: enabledCatalog(),
|
|
3691
4771
|
controller,
|
|
3692
4772
|
});
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
4773
|
+
if (typeof captainRuntime.restore !== 'function') {
|
|
4774
|
+
throw new Error('session Captain runtime does not support restore');
|
|
4775
|
+
}
|
|
4776
|
+
|
|
4777
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4778
|
+
const rootTokens = new Map(
|
|
4779
|
+
Object.entries(snapshot.rootPlayerResumeTokens),
|
|
4780
|
+
);
|
|
4781
|
+
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
4782
|
+
const parentFrame = frames.at(-1);
|
|
4783
|
+
const frame = makeRestoredFrame(
|
|
4784
|
+
enablementById.get(frameSnapshot.playbookId)!,
|
|
4785
|
+
frameSnapshot,
|
|
4786
|
+
rootTokens,
|
|
4787
|
+
index === 0
|
|
4788
|
+
? undefined
|
|
4789
|
+
: {
|
|
4790
|
+
frame: parentFrame!,
|
|
4791
|
+
callId: frameSnapshot.parentCallId!,
|
|
4792
|
+
},
|
|
4793
|
+
);
|
|
4794
|
+
if (typeof frame.runtime.restore !== 'function') {
|
|
4795
|
+
throw new Error(
|
|
4796
|
+
`playbook ${frame.entry.id} runtime does not support restore`,
|
|
4797
|
+
);
|
|
4798
|
+
}
|
|
4799
|
+
frames.push(frame);
|
|
4800
|
+
if (parentFrame) pendingChildParents.add(parentFrame);
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
await captainRuntime.restore(
|
|
4805
|
+
captainPlaybookSession(snapshot.captain.sessionId),
|
|
4806
|
+
snapshot.captain.runtime,
|
|
4807
|
+
);
|
|
4808
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4809
|
+
for (const [index, frame] of frames.entries()) {
|
|
4810
|
+
await frame.runtime.restore!(
|
|
4811
|
+
frameSession(frame),
|
|
4812
|
+
snapshot.frames[index]!.runtime,
|
|
4813
|
+
);
|
|
4814
|
+
}
|
|
4815
|
+
}
|
|
4816
|
+
if (closedGateAttempted) {
|
|
4817
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
4818
|
+
}
|
|
4819
|
+
verifyRestoredRuntime(
|
|
4820
|
+
captainRuntime,
|
|
4821
|
+
snapshot.captain.runtime,
|
|
4822
|
+
INTERNAL_CAPTAIN_ID,
|
|
4823
|
+
false,
|
|
4824
|
+
);
|
|
4825
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4826
|
+
for (const [index, frame] of frames.entries()) {
|
|
4827
|
+
verifyRestoredRuntime(
|
|
4828
|
+
frame.runtime,
|
|
4829
|
+
snapshot.frames[index]!.runtime,
|
|
4830
|
+
frame.entry.id,
|
|
4831
|
+
true,
|
|
4832
|
+
);
|
|
4833
|
+
}
|
|
4834
|
+
if (
|
|
4835
|
+
!isDeepStrictEqual(
|
|
4836
|
+
tokenRecord(rootFrame()!.playerResumeTokens),
|
|
4837
|
+
snapshot.rootPlayerResumeTokens,
|
|
4838
|
+
)
|
|
4839
|
+
) {
|
|
4840
|
+
throw new Error(
|
|
4841
|
+
'restored root-owned player continuation changed during restore',
|
|
4842
|
+
);
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
if (closedGateAttempted) {
|
|
4846
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
4847
|
+
}
|
|
4848
|
+
if (requireSession().signal.aborted) {
|
|
4849
|
+
throw new Error('Captain session aborted during restore');
|
|
4850
|
+
}
|
|
4851
|
+
for (const id of snapshot.issuedSessionIds) issuedSessionIds.add(id);
|
|
4852
|
+
journal.push(...snapshot.journal);
|
|
4853
|
+
journalSeq = snapshot.sequences.journal;
|
|
4854
|
+
turnSequence = snapshot.sequences.turn;
|
|
4855
|
+
conversation = snapshot.captain.conversation;
|
|
4856
|
+
captainSessionId = snapshot.captain.sessionId;
|
|
4857
|
+
lastAction = snapshot.lastAction;
|
|
4858
|
+
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
4859
|
+
mode = snapshot.mode;
|
|
4860
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4861
|
+
pendingBossQuestions = snapshot.pendingBossQuestions;
|
|
4862
|
+
lastError = snapshot.lastError;
|
|
4863
|
+
}
|
|
4864
|
+
lifecycle = 'ready';
|
|
4865
|
+
// The final commit is deliberately one non-throwing assignment.
|
|
4866
|
+
sessionEmissionsOpen = true;
|
|
4867
|
+
} catch (error) {
|
|
4868
|
+
const cleanupFailures = await resetFailedRestore();
|
|
4869
|
+
if (cleanupFailures.length > 0) {
|
|
4870
|
+
throw new AggregateError(
|
|
4871
|
+
[error, ...cleanupFailures],
|
|
4872
|
+
'Captain shell restore and cleanup failed',
|
|
4873
|
+
);
|
|
4874
|
+
}
|
|
4875
|
+
throw error;
|
|
4876
|
+
}
|
|
4877
|
+
};
|
|
4878
|
+
|
|
4879
|
+
return {
|
|
4880
|
+
async init(initSession: CaptainSession): Promise<void> {
|
|
4881
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
4882
|
+
throw new Error('Captain shell requires a fresh instance for init');
|
|
4883
|
+
}
|
|
4884
|
+
if (initSession.signal.aborted) {
|
|
4885
|
+
throw new Error('cannot initialize an aborted Captain session');
|
|
4886
|
+
}
|
|
4887
|
+
lifecycle = 'initializing';
|
|
4888
|
+
try {
|
|
4889
|
+
installSession(initSession, true);
|
|
4890
|
+
players = initSession.players;
|
|
4891
|
+
const built = await buildEnablements(options, players, loadModule);
|
|
4892
|
+
entries = built.entries;
|
|
4893
|
+
byCommand = built.byCommand;
|
|
4894
|
+
byId = built.byId;
|
|
4895
|
+
enablementById = built.enablementById;
|
|
4896
|
+
for (const enablement of enablementById.values()) {
|
|
4897
|
+
enablement.entry.validateOptions(enablement.optionInput);
|
|
4898
|
+
}
|
|
4899
|
+
await setMode('chat', 'init');
|
|
4900
|
+
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
4901
|
+
// engagement stack, with its own playbook session id.
|
|
4902
|
+
captainSessionId = allocateSessionId();
|
|
4903
|
+
captainRuntime = createCaptainRuntime({
|
|
4904
|
+
enabledPlaybooks: enabledCatalog(),
|
|
4905
|
+
controller,
|
|
4906
|
+
});
|
|
4907
|
+
await captainRuntime.init(captainPlaybookSession(captainSessionId));
|
|
4908
|
+
lifecycle = 'ready';
|
|
4909
|
+
} catch (error) {
|
|
4910
|
+
terminallyDisposed = true;
|
|
4911
|
+
lifecycle = 'closed';
|
|
4912
|
+
throw error;
|
|
4913
|
+
}
|
|
3700
4914
|
},
|
|
3701
4915
|
|
|
4916
|
+
exportSnapshot: exportShellSnapshot,
|
|
4917
|
+
|
|
4918
|
+
restore: restoreShellSnapshot,
|
|
4919
|
+
|
|
3702
4920
|
async handleBossTurn(
|
|
3703
4921
|
turn: BossTurn,
|
|
3704
4922
|
context: CaptainContext,
|
|
3705
4923
|
): Promise<void> {
|
|
4924
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
4925
|
+
throw new Error(
|
|
4926
|
+
'init must be called first, or restore must complete before handling a Boss turn',
|
|
4927
|
+
);
|
|
4928
|
+
}
|
|
3706
4929
|
requireSession();
|
|
3707
4930
|
if (!captainRuntime) {
|
|
3708
4931
|
throw new Error('init must be called first');
|
|
@@ -3804,11 +5027,17 @@ export function createPlaybookCaptainShell(
|
|
|
3804
5027
|
},
|
|
3805
5028
|
|
|
3806
5029
|
async prepareDispose(): Promise<void> {
|
|
5030
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
5031
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
5032
|
+
}
|
|
3807
5033
|
activeContext = undefined;
|
|
3808
5034
|
await teardown();
|
|
3809
5035
|
},
|
|
3810
5036
|
|
|
3811
5037
|
async dispose(): Promise<void> {
|
|
5038
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
5039
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
5040
|
+
}
|
|
3812
5041
|
activeContext = undefined;
|
|
3813
5042
|
await teardown();
|
|
3814
5043
|
},
|
|
@@ -3817,6 +5046,8 @@ export function createPlaybookCaptainShell(
|
|
|
3817
5046
|
// CAPTAIN-16: dispose every active frame from leaf to root, then the
|
|
3818
5047
|
// session Captain last.
|
|
3819
5048
|
async function teardown(): Promise<void> {
|
|
5049
|
+
terminallyDisposed = true;
|
|
5050
|
+
lifecycle = 'disposing';
|
|
3820
5051
|
let failure: unknown;
|
|
3821
5052
|
try {
|
|
3822
5053
|
await disposeStack('dispose');
|
|
@@ -3833,6 +5064,7 @@ export function createPlaybookCaptainShell(
|
|
|
3833
5064
|
failure ??= error;
|
|
3834
5065
|
}
|
|
3835
5066
|
}
|
|
5067
|
+
lifecycle = 'closed';
|
|
3836
5068
|
if (failure !== undefined) throw failure;
|
|
3837
5069
|
}
|
|
3838
5070
|
}
|