@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
|
@@ -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,11 +21,15 @@ import type {
|
|
|
20
21
|
PlaybookPorts,
|
|
21
22
|
PlaybookRunResult,
|
|
22
23
|
PlaybookRuntime,
|
|
24
|
+
PlaybookRuntimeSnapshot,
|
|
25
|
+
PlayerSessionStore,
|
|
23
26
|
PlaybookState,
|
|
24
27
|
} from '@sublang/playbook/runtime';
|
|
25
28
|
import {
|
|
29
|
+
assertPlaybookRuntimeSnapshot,
|
|
26
30
|
hiddenControlEnvelope,
|
|
27
31
|
registerPlaybookAbortCleanup,
|
|
32
|
+
snapshotJsonValue,
|
|
28
33
|
} from '../../../src/xstate-runtime.js';
|
|
29
34
|
import createDefaultCaptainRuntime, {
|
|
30
35
|
type CaptainControllerPort,
|
|
@@ -62,6 +67,86 @@ export interface PlaybookCaptainRegistryEntry {
|
|
|
62
67
|
createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
|
|
63
68
|
}
|
|
64
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
|
+
|
|
65
150
|
// Per-enabled-playbook binding the shell resolves at init from
|
|
66
151
|
// `captain.options.playbooks`: each playbook binds its local roles to
|
|
67
152
|
// `<id>-<role>` host players and carries the generated visible set.
|
|
@@ -71,7 +156,11 @@ interface Enablement {
|
|
|
71
156
|
optionInput: unknown;
|
|
72
157
|
boundPlayers: readonly RegistryPlayer[];
|
|
73
158
|
hostPlayerId: (localRole: string) => string;
|
|
74
|
-
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface EffectivePlayerBinding {
|
|
162
|
+
readonly hostPlayerId: string;
|
|
163
|
+
readonly player: RegistryPlayer;
|
|
75
164
|
}
|
|
76
165
|
|
|
77
166
|
interface EngagementFrame {
|
|
@@ -81,6 +170,8 @@ interface EngagementFrame {
|
|
|
81
170
|
sessionId: string;
|
|
82
171
|
rootSessionId: string;
|
|
83
172
|
depth: number;
|
|
173
|
+
playerBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
174
|
+
playerResumeTokens: Map<string, string>;
|
|
84
175
|
parent?: {
|
|
85
176
|
frame: EngagementFrame;
|
|
86
177
|
callId: string;
|
|
@@ -758,6 +849,467 @@ function isValidRegistryEntry(
|
|
|
758
849
|
);
|
|
759
850
|
}
|
|
760
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
|
+
|
|
761
1313
|
function readPlaybooksConfig(
|
|
762
1314
|
options: unknown,
|
|
763
1315
|
): Record<string, unknown> | undefined {
|
|
@@ -874,9 +1426,6 @@ async function buildEnablements(
|
|
|
874
1426
|
optionInput: record.options,
|
|
875
1427
|
boundPlayers,
|
|
876
1428
|
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
877
|
-
visiblePlayerIds: entry.requiredRoleIds.map(
|
|
878
|
-
(role) => `${entry.id}-${role}`,
|
|
879
|
-
),
|
|
880
1429
|
});
|
|
881
1430
|
}
|
|
882
1431
|
return { entries, byCommand, byId, enablementById };
|
|
@@ -885,7 +1434,7 @@ async function buildEnablements(
|
|
|
885
1434
|
export function createPlaybookCaptainShell(
|
|
886
1435
|
options: unknown,
|
|
887
1436
|
deps: PlaybookCaptainDeps = {},
|
|
888
|
-
):
|
|
1437
|
+
): PlaybookCaptainShell {
|
|
889
1438
|
const loadModule =
|
|
890
1439
|
deps.loadModule ?? ((specifier: string) => import(specifier));
|
|
891
1440
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
@@ -901,6 +1450,16 @@ export function createPlaybookCaptainShell(
|
|
|
901
1450
|
let byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
902
1451
|
let enablementById = new Map<string, Enablement>();
|
|
903
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;
|
|
904
1463
|
let players: readonly RegistryPlayer[] = [];
|
|
905
1464
|
let activeContext: CaptainContext | undefined;
|
|
906
1465
|
const frames: EngagementFrame[] = [];
|
|
@@ -914,6 +1473,42 @@ export function createPlaybookCaptainShell(
|
|
|
914
1473
|
const captainQueue = new PQueue({ concurrency: 1 });
|
|
915
1474
|
let disposing = false;
|
|
916
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
|
+
|
|
917
1512
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
918
1513
|
let captainRuntime: PlaybookRuntime | undefined;
|
|
919
1514
|
let captainSessionId: string | undefined;
|
|
@@ -956,6 +1551,29 @@ export function createPlaybookCaptainShell(
|
|
|
956
1551
|
const frameLabel = (frame: EngagementFrame): string =>
|
|
957
1552
|
`/${frame.enablement.command}`;
|
|
958
1553
|
|
|
1554
|
+
const bindingFor = (
|
|
1555
|
+
frame: EngagementFrame,
|
|
1556
|
+
localRole: string,
|
|
1557
|
+
): EffectivePlayerBinding => {
|
|
1558
|
+
const binding = frame.playerBindings.get(localRole);
|
|
1559
|
+
if (!binding) {
|
|
1560
|
+
throw new Error(
|
|
1561
|
+
`${frameLabel(frame)} resolved undeclared player role ${JSON.stringify(localRole)}`,
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
return binding;
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
const summaryIncludes = (frame: EngagementFrame): boolean => {
|
|
1568
|
+
const owner = activeTurnSummary?.owner;
|
|
1569
|
+
if (!owner) return false;
|
|
1570
|
+
for (let current: EngagementFrame | undefined = frame; current; ) {
|
|
1571
|
+
if (current === owner) return true;
|
|
1572
|
+
current = current.parent?.frame;
|
|
1573
|
+
}
|
|
1574
|
+
return false;
|
|
1575
|
+
};
|
|
1576
|
+
|
|
959
1577
|
const requireSession = (): CaptainSession => {
|
|
960
1578
|
if (!session) {
|
|
961
1579
|
throw new Error('init must be called first');
|
|
@@ -1170,7 +1788,8 @@ export function createPlaybookCaptainShell(
|
|
|
1170
1788
|
const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
|
|
1171
1789
|
frame.state = state;
|
|
1172
1790
|
|
|
1173
|
-
|
|
1791
|
+
const summary = activeTurnSummary;
|
|
1792
|
+
if (summary && summaryIncludes(frame)) {
|
|
1174
1793
|
for (const stateId of state.activeStateIds) {
|
|
1175
1794
|
const newlyActive = !previousActiveIds.has(stateId);
|
|
1176
1795
|
const structuredEntry =
|
|
@@ -1179,9 +1798,9 @@ export function createPlaybookCaptainShell(
|
|
|
1179
1798
|
if (!newlyActive && !structuredEntry) continue;
|
|
1180
1799
|
const countLabel = stateCountLabel(stateId, frame.entry);
|
|
1181
1800
|
if (countLabel) {
|
|
1182
|
-
|
|
1801
|
+
summary.stateCounts.set(
|
|
1183
1802
|
countLabel,
|
|
1184
|
-
(
|
|
1803
|
+
(summary.stateCounts.get(countLabel) ?? 0) + 1,
|
|
1185
1804
|
);
|
|
1186
1805
|
}
|
|
1187
1806
|
}
|
|
@@ -1208,12 +1827,13 @@ export function createPlaybookCaptainShell(
|
|
|
1208
1827
|
|
|
1209
1828
|
const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
|
|
1210
1829
|
callPlayer: async (playerId, prompt, signal, options) => {
|
|
1830
|
+
admitHostBoundary();
|
|
1211
1831
|
if (!activeContext) {
|
|
1212
1832
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
1213
1833
|
}
|
|
1214
1834
|
const context = activeContext;
|
|
1215
1835
|
signal.throwIfAborted();
|
|
1216
|
-
const hostPlayerId = frame.
|
|
1836
|
+
const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
|
|
1217
1837
|
const result = await trackHostCall(
|
|
1218
1838
|
frame,
|
|
1219
1839
|
context.callPlayer(hostPlayerId, prompt, {
|
|
@@ -1228,8 +1848,9 @@ export function createPlaybookCaptainShell(
|
|
|
1228
1848
|
// CAPTAIN-20: only a player call that actually produced work is an
|
|
1229
1849
|
// interruption the Boss was spared. A call that errored or aborted
|
|
1230
1850
|
// saved nothing, so it never feeds the saved-counts gate.
|
|
1231
|
-
|
|
1232
|
-
|
|
1851
|
+
const summary = activeTurnSummary;
|
|
1852
|
+
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
1853
|
+
summary.counts.interruptions++;
|
|
1233
1854
|
}
|
|
1234
1855
|
return {
|
|
1235
1856
|
status: result.status,
|
|
@@ -1243,6 +1864,7 @@ export function createPlaybookCaptainShell(
|
|
|
1243
1864
|
};
|
|
1244
1865
|
},
|
|
1245
1866
|
callCaptain: async (prompt, signal, options) => {
|
|
1867
|
+
admitHostBoundary();
|
|
1246
1868
|
if (!activeContext) {
|
|
1247
1869
|
throw new Error('callCaptain invoked outside a Boss turn');
|
|
1248
1870
|
}
|
|
@@ -1266,6 +1888,7 @@ export function createPlaybookCaptainShell(
|
|
|
1266
1888
|
};
|
|
1267
1889
|
},
|
|
1268
1890
|
callJudge: async (prompt, signal) => {
|
|
1891
|
+
admitHostBoundary();
|
|
1269
1892
|
if (!activeContext) {
|
|
1270
1893
|
throw new Error('callJudge invoked outside a Boss turn');
|
|
1271
1894
|
}
|
|
@@ -1289,16 +1912,19 @@ export function createPlaybookCaptainShell(
|
|
|
1289
1912
|
throw new Error('callCaptain returned status=ok with no finalText');
|
|
1290
1913
|
}
|
|
1291
1914
|
const guard = guardFromJudgeReply(result.finalText);
|
|
1915
|
+
const summary = activeTurnSummary;
|
|
1292
1916
|
if (
|
|
1293
1917
|
guard &&
|
|
1294
|
-
|
|
1918
|
+
summary &&
|
|
1919
|
+
summaryIncludes(frame) &&
|
|
1295
1920
|
frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
|
|
1296
1921
|
) {
|
|
1297
|
-
|
|
1922
|
+
summary.counts.copyPastes++;
|
|
1298
1923
|
}
|
|
1299
1924
|
return result.finalText;
|
|
1300
1925
|
},
|
|
1301
1926
|
callPlaybook: (request, signal) => {
|
|
1927
|
+
admitHostBoundary();
|
|
1302
1928
|
const opening = callNestedPlaybook(frame, request, signal);
|
|
1303
1929
|
let exposed!: Promise<PlaybookCallStart>;
|
|
1304
1930
|
const registerOpeningCleanup = (): void => {
|
|
@@ -1311,17 +1937,27 @@ export function createPlaybookCaptainShell(
|
|
|
1311
1937
|
if (signal.aborted) registerOpeningCleanup();
|
|
1312
1938
|
return exposed;
|
|
1313
1939
|
},
|
|
1314
|
-
emitStatus:
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
+
})(),
|
|
1318
1950
|
);
|
|
1319
1951
|
},
|
|
1320
|
-
emitTelemetry:
|
|
1321
|
-
if (
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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);
|
|
1325
1961
|
},
|
|
1326
1962
|
});
|
|
1327
1963
|
|
|
@@ -1329,8 +1965,10 @@ export function createPlaybookCaptainShell(
|
|
|
1329
1965
|
// visibility for that playbook's generated host players. A pane
|
|
1330
1966
|
// reconciliation failure is display-only in tmux-play and does not
|
|
1331
1967
|
// reject; the legacy path carries no generated set and skips this.
|
|
1332
|
-
const requestVisibility = async (
|
|
1333
|
-
const ids =
|
|
1968
|
+
const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
|
|
1969
|
+
const ids = [...new Set(
|
|
1970
|
+
[...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId),
|
|
1971
|
+
)];
|
|
1334
1972
|
if (!ids || ids.length === 0 || !activeContext) return;
|
|
1335
1973
|
try {
|
|
1336
1974
|
await activeContext.setVisiblePlayers(ids);
|
|
@@ -1369,15 +2007,52 @@ export function createPlaybookCaptainShell(
|
|
|
1369
2007
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
1370
2008
|
};
|
|
1371
2009
|
|
|
2010
|
+
const makePlayerBindings = (
|
|
2011
|
+
enablement: Enablement,
|
|
2012
|
+
parent?: { frame: EngagementFrame; callId: string },
|
|
2013
|
+
): ReadonlyMap<string, EffectivePlayerBinding> => {
|
|
2014
|
+
const entry = enablement.entry;
|
|
2015
|
+
const playerBindings = new Map<string, EffectivePlayerBinding>();
|
|
2016
|
+
for (const role of entry.requiredRoleIds) {
|
|
2017
|
+
let inherited: EffectivePlayerBinding | undefined;
|
|
2018
|
+
for (
|
|
2019
|
+
let ancestor = parent?.frame;
|
|
2020
|
+
ancestor && inherited === undefined;
|
|
2021
|
+
ancestor = ancestor.parent?.frame
|
|
2022
|
+
) {
|
|
2023
|
+
inherited = ancestor.playerBindings.get(role);
|
|
2024
|
+
}
|
|
2025
|
+
if (inherited) {
|
|
2026
|
+
playerBindings.set(role, inherited);
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
const configured = enablement.boundPlayers.find(
|
|
2030
|
+
(player) => player.id === role,
|
|
2031
|
+
) ?? { id: role };
|
|
2032
|
+
playerBindings.set(role, {
|
|
2033
|
+
hostPlayerId: enablement.hostPlayerId(role),
|
|
2034
|
+
player: configured,
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
return playerBindings;
|
|
2038
|
+
};
|
|
2039
|
+
|
|
1372
2040
|
const makeFrame = (
|
|
1373
2041
|
enablement: Enablement,
|
|
1374
2042
|
parent?: { frame: EngagementFrame; callId: string },
|
|
1375
2043
|
): EngagementFrame => {
|
|
1376
2044
|
const entry = enablement.entry;
|
|
1377
2045
|
const sessionId = allocateSessionId();
|
|
2046
|
+
const playerBindings = makePlayerBindings(enablement, parent);
|
|
2047
|
+
const playerResumeTokens =
|
|
2048
|
+
parent?.frame.playerResumeTokens ?? new Map<string, string>();
|
|
1378
2049
|
const runtime = entry.createRuntime({
|
|
1379
2050
|
captainOptions: enablement.optionInput,
|
|
1380
|
-
players:
|
|
2051
|
+
players: [...playerBindings].map(([role, { player }]) => ({
|
|
2052
|
+
id: role,
|
|
2053
|
+
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
2054
|
+
...(player.model === undefined ? {} : { model: player.model }),
|
|
2055
|
+
})),
|
|
1381
2056
|
});
|
|
1382
2057
|
return {
|
|
1383
2058
|
entry,
|
|
@@ -1386,13 +2061,77 @@ export function createPlaybookCaptainShell(
|
|
|
1386
2061
|
sessionId,
|
|
1387
2062
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
1388
2063
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
2064
|
+
playerBindings,
|
|
2065
|
+
playerResumeTokens,
|
|
1389
2066
|
...(parent ? { parent } : {}),
|
|
1390
2067
|
inFlightHostCalls: new Set(),
|
|
1391
2068
|
};
|
|
1392
2069
|
};
|
|
1393
2070
|
|
|
1394
|
-
const
|
|
1395
|
-
|
|
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
|
+
|
|
2102
|
+
const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
|
|
2103
|
+
select(playerId) {
|
|
2104
|
+
const binding = bindingFor(frame, playerId);
|
|
2105
|
+
return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
|
|
2106
|
+
},
|
|
2107
|
+
update(playerId, resumeToken) {
|
|
2108
|
+
const binding = bindingFor(frame, playerId);
|
|
2109
|
+
if (resumeToken === undefined) {
|
|
2110
|
+
frame.playerResumeTokens.delete(binding.hostPlayerId);
|
|
2111
|
+
} else {
|
|
2112
|
+
frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
|
|
2113
|
+
}
|
|
2114
|
+
},
|
|
2115
|
+
snapshot() {
|
|
2116
|
+
const tokens: Record<string, string> = {};
|
|
2117
|
+
for (const [playerId, binding] of frame.playerBindings) {
|
|
2118
|
+
const token = frame.playerResumeTokens.get(binding.hostPlayerId);
|
|
2119
|
+
if (token !== undefined) tokens[playerId] = token;
|
|
2120
|
+
}
|
|
2121
|
+
return tokens;
|
|
2122
|
+
},
|
|
2123
|
+
restore(tokens) {
|
|
2124
|
+
for (const binding of frame.playerBindings.values()) {
|
|
2125
|
+
frame.playerResumeTokens.delete(binding.hostPlayerId);
|
|
2126
|
+
}
|
|
2127
|
+
for (const [playerId, token] of Object.entries(tokens)) {
|
|
2128
|
+
const binding = bindingFor(frame, playerId);
|
|
2129
|
+
frame.playerResumeTokens.set(binding.hostPlayerId, token);
|
|
2130
|
+
}
|
|
2131
|
+
},
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
const frameSession = (frame: EngagementFrame) => ({
|
|
1396
2135
|
sessionId: frame.sessionId,
|
|
1397
2136
|
playbookId: frame.entry.id,
|
|
1398
2137
|
rootSessionId: frame.rootSessionId,
|
|
@@ -1403,8 +2142,12 @@ export function createPlaybookCaptainShell(
|
|
|
1403
2142
|
}
|
|
1404
2143
|
: {}),
|
|
1405
2144
|
depth: frame.depth,
|
|
2145
|
+
playerSessions: playerSessionStore(frame),
|
|
1406
2146
|
ports: createPorts(frame),
|
|
1407
2147
|
});
|
|
2148
|
+
|
|
2149
|
+
const initFrame = async (frame: EngagementFrame): Promise<void> => {
|
|
2150
|
+
await frame.runtime.init(frameSession(frame));
|
|
1408
2151
|
};
|
|
1409
2152
|
|
|
1410
2153
|
const clearLeafLedger = (): void => {
|
|
@@ -1599,7 +2342,7 @@ export function createPlaybookCaptainShell(
|
|
|
1599
2342
|
}
|
|
1600
2343
|
let visibilityError: unknown;
|
|
1601
2344
|
try {
|
|
1602
|
-
await requestVisibility(parent
|
|
2345
|
+
await requestVisibility(parent);
|
|
1603
2346
|
} catch (error) {
|
|
1604
2347
|
visibilityError = error;
|
|
1605
2348
|
}
|
|
@@ -1720,7 +2463,7 @@ export function createPlaybookCaptainShell(
|
|
|
1720
2463
|
// boundary — a `setVisiblePlayers` or telemetry rejection here leaves the
|
|
1721
2464
|
// runtime uninvoked and owes the Boss the CAPTAIN-34 reply rather than an
|
|
1722
2465
|
// exception filed against an effect that never ran.
|
|
1723
|
-
await requestVisibility(frame
|
|
2466
|
+
await requestVisibility(frame);
|
|
1724
2467
|
await setMode('engaged.driving', 'submit');
|
|
1725
2468
|
const result = await runEffect(() =>
|
|
1726
2469
|
frame.runtime.handleBossInput({ text, signal }),
|
|
@@ -1824,6 +2567,12 @@ export function createPlaybookCaptainShell(
|
|
|
1824
2567
|
if (frame.parent) {
|
|
1825
2568
|
await resumeParent(frame, callResultFor(frame, result), context);
|
|
1826
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));
|
|
1827
2576
|
await runEffect(() => disposeStack('final'));
|
|
1828
2577
|
}
|
|
1829
2578
|
return;
|
|
@@ -1867,7 +2616,7 @@ export function createPlaybookCaptainShell(
|
|
|
1867
2616
|
cleanupError = error;
|
|
1868
2617
|
}
|
|
1869
2618
|
if (frames.includes(parent)) {
|
|
1870
|
-
await requestVisibility(parent
|
|
2619
|
+
await requestVisibility(parent);
|
|
1871
2620
|
}
|
|
1872
2621
|
if (cleanupError !== undefined) throw cleanupError;
|
|
1873
2622
|
};
|
|
@@ -2005,7 +2754,7 @@ export function createPlaybookCaptainShell(
|
|
|
2005
2754
|
}
|
|
2006
2755
|
if (frames.includes(parent)) {
|
|
2007
2756
|
try {
|
|
2008
|
-
await requestVisibility(parent
|
|
2757
|
+
await requestVisibility(parent);
|
|
2009
2758
|
} catch (visibilityError) {
|
|
2010
2759
|
visibilityControlFailure = true;
|
|
2011
2760
|
boundaryError = new AggregateError(
|
|
@@ -2655,9 +3404,11 @@ export function createPlaybookCaptainShell(
|
|
|
2655
3404
|
|
|
2656
3405
|
const captainPorts = (): PlaybookPorts => ({
|
|
2657
3406
|
callPlayer: async () => {
|
|
3407
|
+
admitHostBoundary();
|
|
2658
3408
|
throw new Error('the session Captain has no players');
|
|
2659
3409
|
},
|
|
2660
3410
|
callCaptain: async (prompt, signal) => {
|
|
3411
|
+
admitHostBoundary();
|
|
2661
3412
|
if (!activeContext) {
|
|
2662
3413
|
throw new Error('the session Captain called out of a Boss turn');
|
|
2663
3414
|
}
|
|
@@ -2725,29 +3476,37 @@ export function createPlaybookCaptainShell(
|
|
|
2725
3476
|
return { status: 'ok' as const, finalText: 'ok' };
|
|
2726
3477
|
},
|
|
2727
3478
|
callJudge: async () => {
|
|
3479
|
+
admitHostBoundary();
|
|
2728
3480
|
throw new Error('the session Captain makes no judge call');
|
|
2729
3481
|
},
|
|
2730
3482
|
callPlaybook: async () => {
|
|
3483
|
+
admitHostBoundary();
|
|
2731
3484
|
throw new Error('the session Captain never calls a playbook');
|
|
2732
3485
|
},
|
|
2733
3486
|
// CAPTAIN-9: the session Captain's human status stream is suppressed while
|
|
2734
3487
|
// its structured telemetry is forwarded.
|
|
2735
|
-
emitStatus: async () => {
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
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
|
+
}
|
|
2748
3506
|
}
|
|
2749
|
-
|
|
2750
|
-
|
|
3507
|
+
await requireSession().emitTelemetry(event);
|
|
3508
|
+
})();
|
|
3509
|
+
return trackTurnCall(emission);
|
|
2751
3510
|
},
|
|
2752
3511
|
});
|
|
2753
3512
|
|
|
@@ -2826,6 +3585,15 @@ export function createPlaybookCaptainShell(
|
|
|
2826
3585
|
}
|
|
2827
3586
|
};
|
|
2828
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
|
+
|
|
2829
3597
|
const leafStateSummary = (): string | undefined => {
|
|
2830
3598
|
const leaf = leafFrame();
|
|
2831
3599
|
if (!leaf) return 'idle: no playbook is engaged';
|
|
@@ -3308,9 +4076,6 @@ export function createPlaybookCaptainShell(
|
|
|
3308
4076
|
};
|
|
3309
4077
|
throw outcome.error;
|
|
3310
4078
|
}
|
|
3311
|
-
if (!frames.includes(leaf)) {
|
|
3312
|
-
facts.push(`${frameLabel(leaf)} finished and was disposed.`);
|
|
3313
|
-
}
|
|
3314
4079
|
const runFailed = drainRunFailureFacts(facts);
|
|
3315
4080
|
const summary = leafStateSummary();
|
|
3316
4081
|
turn.report = {
|
|
@@ -3485,9 +4250,14 @@ export function createPlaybookCaptainShell(
|
|
|
3485
4250
|
};
|
|
3486
4251
|
|
|
3487
4252
|
const controller: CaptainControllerPort = {
|
|
3488
|
-
submit: (selection, signal) =>
|
|
3489
|
-
|
|
3490
|
-
|
|
4253
|
+
submit: (selection, signal) => {
|
|
4254
|
+
admitHostBoundary();
|
|
4255
|
+
return settleSelection(selection, signal);
|
|
4256
|
+
},
|
|
4257
|
+
resolveParsedTurn: () => {
|
|
4258
|
+
admitHostBoundary();
|
|
4259
|
+
return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
|
|
4260
|
+
},
|
|
3491
4261
|
};
|
|
3492
4262
|
|
|
3493
4263
|
// -------------------------------------------------------------------------
|
|
@@ -3565,48 +4335,597 @@ export function createPlaybookCaptainShell(
|
|
|
3565
4335
|
});
|
|
3566
4336
|
};
|
|
3567
4337
|
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
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);
|
|
3571
4763
|
players = initSession.players;
|
|
3572
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
3573
4764
|
entries = built.entries;
|
|
3574
4765
|
byCommand = built.byCommand;
|
|
3575
4766
|
byId = built.byId;
|
|
3576
4767
|
enablementById = built.enablementById;
|
|
3577
|
-
|
|
3578
|
-
enablement.entry.validateOptions(enablement.optionInput);
|
|
3579
|
-
}
|
|
3580
|
-
await setMode('chat', 'init');
|
|
3581
|
-
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
3582
|
-
// engagement stack, with its own playbook session id.
|
|
3583
|
-
const catalog = Object.freeze(
|
|
3584
|
-
entries.map((entry) =>
|
|
3585
|
-
Object.freeze({
|
|
3586
|
-
id: entry.id,
|
|
3587
|
-
command: enablementById.get(entry.id)!.command,
|
|
3588
|
-
intent: entry.intent,
|
|
3589
|
-
}),
|
|
3590
|
-
),
|
|
3591
|
-
);
|
|
3592
|
-
captainSessionId = allocateSessionId();
|
|
4768
|
+
|
|
3593
4769
|
captainRuntime = createCaptainRuntime({
|
|
3594
|
-
enabledPlaybooks:
|
|
4770
|
+
enabledPlaybooks: enabledCatalog(),
|
|
3595
4771
|
controller,
|
|
3596
4772
|
});
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
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
|
+
}
|
|
3604
4914
|
},
|
|
3605
4915
|
|
|
4916
|
+
exportSnapshot: exportShellSnapshot,
|
|
4917
|
+
|
|
4918
|
+
restore: restoreShellSnapshot,
|
|
4919
|
+
|
|
3606
4920
|
async handleBossTurn(
|
|
3607
4921
|
turn: BossTurn,
|
|
3608
4922
|
context: CaptainContext,
|
|
3609
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
|
+
}
|
|
3610
4929
|
requireSession();
|
|
3611
4930
|
if (!captainRuntime) {
|
|
3612
4931
|
throw new Error('init must be called first');
|
|
@@ -3708,11 +5027,17 @@ export function createPlaybookCaptainShell(
|
|
|
3708
5027
|
},
|
|
3709
5028
|
|
|
3710
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
|
+
}
|
|
3711
5033
|
activeContext = undefined;
|
|
3712
5034
|
await teardown();
|
|
3713
5035
|
},
|
|
3714
5036
|
|
|
3715
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
|
+
}
|
|
3716
5041
|
activeContext = undefined;
|
|
3717
5042
|
await teardown();
|
|
3718
5043
|
},
|
|
@@ -3721,6 +5046,8 @@ export function createPlaybookCaptainShell(
|
|
|
3721
5046
|
// CAPTAIN-16: dispose every active frame from leaf to root, then the
|
|
3722
5047
|
// session Captain last.
|
|
3723
5048
|
async function teardown(): Promise<void> {
|
|
5049
|
+
terminallyDisposed = true;
|
|
5050
|
+
lifecycle = 'disposing';
|
|
3724
5051
|
let failure: unknown;
|
|
3725
5052
|
try {
|
|
3726
5053
|
await disposeStack('dispose');
|
|
@@ -3737,6 +5064,7 @@ export function createPlaybookCaptainShell(
|
|
|
3737
5064
|
failure ??= error;
|
|
3738
5065
|
}
|
|
3739
5066
|
}
|
|
5067
|
+
lifecycle = 'closed';
|
|
3740
5068
|
if (failure !== undefined) throw failure;
|
|
3741
5069
|
}
|
|
3742
5070
|
}
|