@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
package/src/xstate-runtime.js
CHANGED
|
@@ -526,58 +526,135 @@ const SNAPSHOT_SEQUENCE_KEYS = [
|
|
|
526
526
|
'playerCall',
|
|
527
527
|
'playbookCall',
|
|
528
528
|
];
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
529
|
+
function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
|
|
530
|
+
const captured = snapshotJsonValue(value, path);
|
|
531
|
+
if (!isRecord(captured)) {
|
|
532
|
+
throw new TypeError(`${path} must be an object`);
|
|
533
|
+
}
|
|
534
|
+
rejectUnknownKeys(captured, ['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'], path);
|
|
535
|
+
const call = {
|
|
536
|
+
callId: requireNonEmptyString(captured.callId, `${path}.callId`),
|
|
537
|
+
stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
|
|
538
|
+
playbookId: requireNonEmptyString(captured.playbookId, `${path}.playbookId`),
|
|
539
|
+
text: requireNonEmptyString(captured.text, `${path}.text`),
|
|
540
|
+
childSessionId: requireNonEmptyString(captured.childSessionId, `${path}.childSessionId`),
|
|
541
|
+
};
|
|
542
|
+
if (own(captured, 'turnId')) {
|
|
543
|
+
if (!Number.isSafeInteger(captured.turnId) ||
|
|
544
|
+
captured.turnId <= 0) {
|
|
545
|
+
throw new TypeError(`${path}.turnId must be a positive integer`);
|
|
546
|
+
}
|
|
547
|
+
call.turnId = captured.turnId;
|
|
548
|
+
}
|
|
549
|
+
return Object.freeze(call);
|
|
550
|
+
}
|
|
551
|
+
// DR-014 §1 / DR-031 §5: validate and detach a host-supplied runtime
|
|
552
|
+
// snapshot before restore touches any state. A suspended schema-2 call is
|
|
553
|
+
// rejected unless the restore path explicitly promises to seed and claim it.
|
|
554
|
+
export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options = {}) {
|
|
555
|
+
const snapshot = snapshotJsonValue(value, 'runtime snapshot');
|
|
556
|
+
if (!isRecord(snapshot)) {
|
|
534
557
|
throw new TypeError('runtime snapshot must be an object');
|
|
535
558
|
}
|
|
536
|
-
|
|
537
|
-
|
|
559
|
+
const capturedOptions = snapshotJsonValue(options, 'runtime snapshot validation options');
|
|
560
|
+
if (!isRecord(capturedOptions)) {
|
|
561
|
+
throw new TypeError('runtime snapshot validation options must be an object');
|
|
562
|
+
}
|
|
563
|
+
rejectUnknownKeys(capturedOptions, ['allowSuspendedCall'], 'runtime snapshot validation options');
|
|
564
|
+
if (capturedOptions.allowSuspendedCall !== undefined &&
|
|
565
|
+
typeof capturedOptions.allowSuspendedCall !== 'boolean') {
|
|
566
|
+
throw new TypeError('runtime snapshot validation options.allowSuspendedCall must be boolean');
|
|
567
|
+
}
|
|
568
|
+
const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
|
|
569
|
+
if (snapshot.schemaVersion !== 1 && snapshot.schemaVersion !== 2) {
|
|
570
|
+
throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1 or 2)`);
|
|
571
|
+
}
|
|
572
|
+
const schemaVersion = snapshot.schemaVersion;
|
|
573
|
+
rejectUnknownKeys(snapshot, [
|
|
574
|
+
'schemaVersion',
|
|
575
|
+
'playbookId',
|
|
576
|
+
'machine',
|
|
577
|
+
'playerResumeTokens',
|
|
578
|
+
'sequences',
|
|
579
|
+
'state',
|
|
580
|
+
'pendingBossQuestions',
|
|
581
|
+
'suspendedCall',
|
|
582
|
+
], 'runtime snapshot');
|
|
583
|
+
if (schemaVersion === 1 && own(snapshot, 'suspendedCall')) {
|
|
584
|
+
throw new TypeError('runtime snapshot schemaVersion 1 must not carry suspendedCall');
|
|
585
|
+
}
|
|
586
|
+
let suspendedCall;
|
|
587
|
+
if (schemaVersion === 2 && own(snapshot, 'suspendedCall')) {
|
|
588
|
+
suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
|
|
589
|
+
if (!allowSuspendedCall) {
|
|
590
|
+
throw new TypeError('runtime snapshot suspendedCall requires a restore path that explicitly allows it');
|
|
591
|
+
}
|
|
538
592
|
}
|
|
539
|
-
const playbookId = requireNonEmptyString(
|
|
593
|
+
const playbookId = requireNonEmptyString(snapshot.playbookId, 'runtime snapshot playbookId');
|
|
540
594
|
if (playbookId !== expectedPlaybookId) {
|
|
541
595
|
throw new TypeError(`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`);
|
|
542
596
|
}
|
|
543
|
-
if (!isRecord(
|
|
597
|
+
if (!isRecord(snapshot.machine)) {
|
|
544
598
|
throw new TypeError('runtime snapshot machine must be an object');
|
|
545
599
|
}
|
|
546
|
-
const machine =
|
|
547
|
-
if (!isRecord(
|
|
600
|
+
const machine = snapshot.machine;
|
|
601
|
+
if (!isRecord(snapshot.playerResumeTokens)) {
|
|
548
602
|
throw new TypeError('runtime snapshot playerResumeTokens must be an object');
|
|
549
603
|
}
|
|
550
604
|
const playerResumeTokens = {};
|
|
551
|
-
for (const [playerId, token] of Object.entries(
|
|
605
|
+
for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
|
|
552
606
|
defineEnumerableDataProperty(playerResumeTokens, playerId, requireNonEmptyString(token, `runtime snapshot playerResumeTokens.${playerId}`));
|
|
553
607
|
}
|
|
554
|
-
if (!isRecord(
|
|
608
|
+
if (!isRecord(snapshot.sequences)) {
|
|
555
609
|
throw new TypeError('runtime snapshot sequences must be an object');
|
|
556
610
|
}
|
|
611
|
+
rejectUnknownKeys(snapshot.sequences, [...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'], 'runtime snapshot sequences');
|
|
557
612
|
const sequences = {};
|
|
558
613
|
for (const key of SNAPSHOT_SEQUENCE_KEYS) {
|
|
559
|
-
const sequence =
|
|
614
|
+
const sequence = snapshot.sequences[key];
|
|
560
615
|
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
561
616
|
throw new TypeError(`runtime snapshot sequences.${key} must be a non-negative integer`);
|
|
562
617
|
}
|
|
563
618
|
sequences[key] = sequence;
|
|
564
619
|
}
|
|
565
|
-
const captainCall =
|
|
620
|
+
const captainCall = snapshot.sequences.captainCall;
|
|
566
621
|
if (captainCall !== undefined) {
|
|
567
622
|
if (!Number.isSafeInteger(captainCall) || captainCall < 0) {
|
|
568
623
|
throw new TypeError('runtime snapshot sequences.captainCall must be a non-negative integer');
|
|
569
624
|
}
|
|
570
625
|
sequences.captainCall = captainCall;
|
|
571
626
|
}
|
|
572
|
-
validateState(
|
|
573
|
-
const state =
|
|
574
|
-
if (
|
|
627
|
+
validateState(snapshot.state, 'runtime snapshot state');
|
|
628
|
+
const state = snapshot.state;
|
|
629
|
+
if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
|
|
630
|
+
throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires schemaVersion 2 suspendedCall`);
|
|
631
|
+
}
|
|
632
|
+
if (suspendedCall) {
|
|
633
|
+
if (sequences.playbookCall === 0) {
|
|
634
|
+
throw new TypeError('runtime snapshot suspendedCall requires sequences.playbookCall greater than zero');
|
|
635
|
+
}
|
|
636
|
+
if (suspendedCall.turnId !== undefined &&
|
|
637
|
+
suspendedCall.turnId > sequences.turn) {
|
|
638
|
+
throw new TypeError('runtime snapshot suspendedCall.turnId must not exceed sequences.turn');
|
|
639
|
+
}
|
|
640
|
+
if (state.status !== 'active' || !state.quiescent) {
|
|
641
|
+
throw new TypeError('runtime snapshot suspendedCall requires an active quiescent state');
|
|
642
|
+
}
|
|
643
|
+
if (!state.tags.includes(SUSPENDED_TAG)) {
|
|
644
|
+
throw new TypeError(`runtime snapshot suspendedCall requires state tag ${SUSPENDED_TAG}`);
|
|
645
|
+
}
|
|
646
|
+
if (!state.activeStateIds.includes(suspendedCall.stateId)) {
|
|
647
|
+
throw new TypeError('runtime snapshot suspendedCall.stateId must be active in snapshot state');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (!Array.isArray(snapshot.pendingBossQuestions)) {
|
|
575
651
|
throw new TypeError('runtime snapshot pendingBossQuestions must be an array');
|
|
576
652
|
}
|
|
577
|
-
const pendingBossQuestions =
|
|
653
|
+
const pendingBossQuestions = snapshot.pendingBossQuestions.map((entry, index) => {
|
|
578
654
|
const path = `runtime snapshot pendingBossQuestions[${index}]`;
|
|
579
655
|
if (!isRecord(entry))
|
|
580
656
|
throw new TypeError(`${path} must be an object`);
|
|
657
|
+
rejectUnknownKeys(entry, ['questionId', 'player', 'question', 'sourceItem'], path);
|
|
581
658
|
const question = {
|
|
582
659
|
questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
|
|
583
660
|
player: requireNonEmptyString(entry.player, `${path}.player`),
|
|
@@ -590,14 +667,21 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
|
|
|
590
667
|
};
|
|
591
668
|
return Object.freeze(question);
|
|
592
669
|
});
|
|
593
|
-
|
|
594
|
-
schemaVersion: 1,
|
|
670
|
+
const fields = {
|
|
595
671
|
playbookId,
|
|
596
672
|
machine,
|
|
597
673
|
playerResumeTokens: Object.freeze(playerResumeTokens),
|
|
598
674
|
sequences: Object.freeze(sequences),
|
|
599
675
|
state,
|
|
600
676
|
pendingBossQuestions: Object.freeze(pendingBossQuestions),
|
|
677
|
+
};
|
|
678
|
+
if (schemaVersion === 1) {
|
|
679
|
+
return Object.freeze({ schemaVersion: 1, ...fields });
|
|
680
|
+
}
|
|
681
|
+
return Object.freeze({
|
|
682
|
+
schemaVersion: 2,
|
|
683
|
+
...fields,
|
|
684
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
601
685
|
});
|
|
602
686
|
}
|
|
603
687
|
export class NestedPlaybookCallError extends Error {
|
|
@@ -821,6 +905,7 @@ function outputOrThrow(result) {
|
|
|
821
905
|
}
|
|
822
906
|
export function createNestedPlaybookBridge(options) {
|
|
823
907
|
let current;
|
|
908
|
+
let restoreMode;
|
|
824
909
|
let disposed = false;
|
|
825
910
|
const usedCallIds = new Set();
|
|
826
911
|
const pendingListeners = new Set();
|
|
@@ -853,10 +938,29 @@ export function createNestedPlaybookBridge(options) {
|
|
|
853
938
|
childSessionId: active.childSessionId,
|
|
854
939
|
}
|
|
855
940
|
: undefined;
|
|
856
|
-
const
|
|
941
|
+
const suspendedIdentity = (active) => active?.phase === 'suspended' && active.childSessionId
|
|
942
|
+
? Object.freeze({
|
|
943
|
+
callId: active.callId,
|
|
944
|
+
stateId: active.input.stateId,
|
|
945
|
+
playbookId: active.input.playbookId,
|
|
946
|
+
text: active.input.text,
|
|
947
|
+
childSessionId: active.childSessionId,
|
|
948
|
+
...(active.turnId === undefined ? {} : { turnId: active.turnId }),
|
|
949
|
+
})
|
|
950
|
+
: undefined;
|
|
951
|
+
const failRestoreMode = (mode, error) => {
|
|
952
|
+
mode.state = 'failed';
|
|
953
|
+
mode.error = error;
|
|
954
|
+
reportControlPlaneError(error);
|
|
955
|
+
};
|
|
956
|
+
const detachAbortListener = (active) => {
|
|
857
957
|
if (active.abortListener) {
|
|
858
958
|
active.signal.removeEventListener('abort', active.abortListener);
|
|
959
|
+
active.abortListener = undefined;
|
|
859
960
|
}
|
|
961
|
+
};
|
|
962
|
+
const clear = (active) => {
|
|
963
|
+
detachAbortListener(active);
|
|
860
964
|
if (current === active)
|
|
861
965
|
current = undefined;
|
|
862
966
|
};
|
|
@@ -972,23 +1076,171 @@ export function createNestedPlaybookBridge(options) {
|
|
|
972
1076
|
throw error;
|
|
973
1077
|
}
|
|
974
1078
|
};
|
|
1079
|
+
const rollbackRestoredCall = (mode, error) => {
|
|
1080
|
+
const active = mode.active;
|
|
1081
|
+
mode.state = 'failed';
|
|
1082
|
+
mode.error = error;
|
|
1083
|
+
mode.active = undefined;
|
|
1084
|
+
if (!active)
|
|
1085
|
+
return undefined;
|
|
1086
|
+
active.phase = 'settling';
|
|
1087
|
+
active.restoreRolledBack = true;
|
|
1088
|
+
clear(active);
|
|
1089
|
+
usedCallIds.delete(active.callId);
|
|
1090
|
+
active.deferred.reject(error);
|
|
1091
|
+
return active;
|
|
1092
|
+
};
|
|
1093
|
+
const publishSuspendedCall = (active) => {
|
|
1094
|
+
if (active.phase !== 'suspended') {
|
|
1095
|
+
throw new Error(`playbook call ${active.callId} is not suspended`);
|
|
1096
|
+
}
|
|
1097
|
+
const abortListener = () => {
|
|
1098
|
+
if (active.phase !== 'suspended')
|
|
1099
|
+
return;
|
|
1100
|
+
const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ?? new Error('Nested playbook invocation aborted'), true);
|
|
1101
|
+
void settlePending(active, result).catch((error) => {
|
|
1102
|
+
reportBackgroundError(error);
|
|
1103
|
+
});
|
|
1104
|
+
};
|
|
1105
|
+
active.abortListener = abortListener;
|
|
1106
|
+
active.signal.addEventListener('abort', abortListener, { once: true });
|
|
1107
|
+
const pendingCall = pendingIdentity(active);
|
|
1108
|
+
if (!pendingCall) {
|
|
1109
|
+
throw new Error('suspended call identity was not recorded');
|
|
1110
|
+
}
|
|
1111
|
+
for (const listener of pendingListeners) {
|
|
1112
|
+
try {
|
|
1113
|
+
listener(pendingCall);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
reportBackgroundError(error);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (active.signal.aborted)
|
|
1120
|
+
abortListener();
|
|
1121
|
+
};
|
|
1122
|
+
const waitOnSuspendedCall = async (active) => {
|
|
1123
|
+
publishSuspendedCall(active);
|
|
1124
|
+
return await active.deferred.promise;
|
|
1125
|
+
};
|
|
975
1126
|
const actorLogic = fromPromise(async ({ input, signal: invocationSignal }) => {
|
|
976
1127
|
if (disposed) {
|
|
977
1128
|
rejectControlPlane(new Error('nested playbook bridge is disposed'));
|
|
978
1129
|
}
|
|
1130
|
+
const normalizedInput = (() => {
|
|
1131
|
+
try {
|
|
1132
|
+
return {
|
|
1133
|
+
stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
|
|
1134
|
+
playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
|
|
1135
|
+
text: requireNonEmptyString(input.text, 'playbook input text'),
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
catch (error) {
|
|
1139
|
+
const mode = restoreMode;
|
|
1140
|
+
if (mode) {
|
|
1141
|
+
if (mode.state === 'claimed') {
|
|
1142
|
+
rollbackRestoredCall(mode, error);
|
|
1143
|
+
reportControlPlaneError(error);
|
|
1144
|
+
}
|
|
1145
|
+
else
|
|
1146
|
+
failRestoreMode(mode, error);
|
|
1147
|
+
throw error;
|
|
1148
|
+
}
|
|
1149
|
+
return rejectControlPlane(error);
|
|
1150
|
+
}
|
|
1151
|
+
})();
|
|
1152
|
+
const mode = restoreMode;
|
|
1153
|
+
if (mode) {
|
|
1154
|
+
if (mode.state !== 'armed') {
|
|
1155
|
+
const callId = mode.call?.callId ?? 'without a descriptor';
|
|
1156
|
+
const error = new Error(mode.state === 'claimed'
|
|
1157
|
+
? `restored playbook call ${callId} was claimed more than once`
|
|
1158
|
+
: `restored playbook call ${callId} is no longer claimable`);
|
|
1159
|
+
if (mode.state === 'claimed')
|
|
1160
|
+
rollbackRestoredCall(mode, error);
|
|
1161
|
+
else
|
|
1162
|
+
mode.error ??= error;
|
|
1163
|
+
reportControlPlaneError(error);
|
|
1164
|
+
throw error;
|
|
1165
|
+
}
|
|
1166
|
+
const seed = mode.call;
|
|
1167
|
+
if (!seed) {
|
|
1168
|
+
const error = new Error('restored machine invoked a nested playbook without a suspendedCall descriptor');
|
|
1169
|
+
failRestoreMode(mode, error);
|
|
1170
|
+
throw error;
|
|
1171
|
+
}
|
|
1172
|
+
for (const field of ['stateId', 'playbookId', 'text']) {
|
|
1173
|
+
if (normalizedInput[field] !== seed[field]) {
|
|
1174
|
+
const error = new Error(`restored playbook call ${seed.callId} ${field} does not match its persisted input`);
|
|
1175
|
+
failRestoreMode(mode, error);
|
|
1176
|
+
throw error;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (usedCallIds.has(seed.callId)) {
|
|
1180
|
+
const error = new Error(`restored duplicate playbook call id ${seed.callId}`);
|
|
1181
|
+
failRestoreMode(mode, error);
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
const controller = new AbortController();
|
|
1185
|
+
let callSignal;
|
|
1186
|
+
try {
|
|
1187
|
+
callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
|
|
1188
|
+
}
|
|
1189
|
+
catch (error) {
|
|
1190
|
+
failRestoreMode(mode, error);
|
|
1191
|
+
throw error;
|
|
1192
|
+
}
|
|
1193
|
+
const active = {
|
|
1194
|
+
callId: seed.callId,
|
|
1195
|
+
input: normalizedInput,
|
|
1196
|
+
...(seed.turnId === undefined
|
|
1197
|
+
? {}
|
|
1198
|
+
: { turnId: seed.turnId }),
|
|
1199
|
+
deferred: deferred(),
|
|
1200
|
+
finished: deferred(),
|
|
1201
|
+
controller,
|
|
1202
|
+
signal: callSignal,
|
|
1203
|
+
phase: 'restoring',
|
|
1204
|
+
childSessionId: seed.childSessionId,
|
|
1205
|
+
};
|
|
1206
|
+
usedCallIds.add(active.callId);
|
|
1207
|
+
current = active;
|
|
1208
|
+
mode.state = 'claimed';
|
|
1209
|
+
mode.active = active;
|
|
1210
|
+
const restoreAbortListener = () => {
|
|
1211
|
+
if (restoreMode !== mode ||
|
|
1212
|
+
mode.state !== 'claimed' ||
|
|
1213
|
+
mode.active !== active ||
|
|
1214
|
+
active.phase !== 'restoring') {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
rollbackRestoredCall(mode, active.signal.reason ??
|
|
1218
|
+
new Error('Restored nested playbook invocation aborted'));
|
|
1219
|
+
};
|
|
1220
|
+
active.abortListener = restoreAbortListener;
|
|
1221
|
+
active.signal.addEventListener('abort', restoreAbortListener, {
|
|
1222
|
+
once: true,
|
|
1223
|
+
});
|
|
1224
|
+
if (active.signal.aborted)
|
|
1225
|
+
restoreAbortListener();
|
|
1226
|
+
try {
|
|
1227
|
+
return await active.deferred.promise;
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
if (!active.restoreRolledBack)
|
|
1231
|
+
active.runError = error;
|
|
1232
|
+
throw error;
|
|
1233
|
+
}
|
|
1234
|
+
finally {
|
|
1235
|
+
active.finished.resolve(undefined);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
979
1238
|
if (current) {
|
|
980
1239
|
rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
|
|
981
1240
|
}
|
|
982
|
-
const
|
|
1241
|
+
const callId = (() => {
|
|
983
1242
|
try {
|
|
984
|
-
return
|
|
985
|
-
{
|
|
986
|
-
stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
|
|
987
|
-
playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
|
|
988
|
-
text: requireNonEmptyString(input.text, 'playbook input text'),
|
|
989
|
-
},
|
|
990
|
-
requireNonEmptyString(options.nextCallId(), 'allocated playbook call id'),
|
|
991
|
-
];
|
|
1243
|
+
return requireNonEmptyString(options.nextCallId(), 'allocated playbook call id');
|
|
992
1244
|
}
|
|
993
1245
|
catch (error) {
|
|
994
1246
|
return rejectControlPlane(error);
|
|
@@ -1119,32 +1371,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1119
1371
|
}
|
|
1120
1372
|
active.phase = 'suspended';
|
|
1121
1373
|
active.childSessionId = start.childSessionId;
|
|
1122
|
-
|
|
1123
|
-
if (active.phase !== 'suspended')
|
|
1124
|
-
return;
|
|
1125
|
-
const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
|
|
1126
|
-
new Error('Nested playbook invocation aborted'), true);
|
|
1127
|
-
void settlePending(active, result).catch((error) => {
|
|
1128
|
-
reportBackgroundError(error);
|
|
1129
|
-
});
|
|
1130
|
-
};
|
|
1131
|
-
active.abortListener = abortListener;
|
|
1132
|
-
active.signal.addEventListener('abort', abortListener, { once: true });
|
|
1133
|
-
const pendingCall = pendingIdentity(active);
|
|
1134
|
-
if (!pendingCall) {
|
|
1135
|
-
throw new Error('suspended call identity was not recorded');
|
|
1136
|
-
}
|
|
1137
|
-
for (const listener of pendingListeners) {
|
|
1138
|
-
try {
|
|
1139
|
-
listener(pendingCall);
|
|
1140
|
-
}
|
|
1141
|
-
catch (error) {
|
|
1142
|
-
reportBackgroundError(error);
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
if (active.signal.aborted)
|
|
1146
|
-
abortListener();
|
|
1147
|
-
return await active.deferred.promise;
|
|
1374
|
+
return await waitOnSuspendedCall(active);
|
|
1148
1375
|
}
|
|
1149
1376
|
catch (error) {
|
|
1150
1377
|
active.runError = error;
|
|
@@ -1155,6 +1382,16 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1155
1382
|
}
|
|
1156
1383
|
});
|
|
1157
1384
|
const abortPending = async (error = new Error('Nested playbook call aborted')) => {
|
|
1385
|
+
const mode = restoreMode;
|
|
1386
|
+
if (mode) {
|
|
1387
|
+
restoreMode = undefined;
|
|
1388
|
+
const restored = mode.state === 'claimed'
|
|
1389
|
+
? rollbackRestoredCall(mode, error)
|
|
1390
|
+
: undefined;
|
|
1391
|
+
if (restored)
|
|
1392
|
+
await restored.finished.promise;
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1158
1395
|
const active = current;
|
|
1159
1396
|
if (!active)
|
|
1160
1397
|
return;
|
|
@@ -1182,6 +1419,70 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1182
1419
|
return {
|
|
1183
1420
|
actorLogic,
|
|
1184
1421
|
getPendingCall: () => pendingIdentity(current),
|
|
1422
|
+
getSuspendedCall: () => suspendedIdentity(current),
|
|
1423
|
+
prepareRestore(call) {
|
|
1424
|
+
// Capture the complete host-owned descriptor before observing or
|
|
1425
|
+
// mutating bridge state, so a rejected preparation cannot leave state.
|
|
1426
|
+
const captured = call === undefined
|
|
1427
|
+
? undefined
|
|
1428
|
+
: snapshotSuspendedCall(call, 'restored playbook call');
|
|
1429
|
+
if (disposed) {
|
|
1430
|
+
rejectControlPlane(new Error('nested playbook bridge is disposed'));
|
|
1431
|
+
}
|
|
1432
|
+
if (current) {
|
|
1433
|
+
rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
|
|
1434
|
+
}
|
|
1435
|
+
if (restoreMode) {
|
|
1436
|
+
rejectControlPlane(new Error('nested playbook bridge restore is already prepared'));
|
|
1437
|
+
}
|
|
1438
|
+
if (captured && usedCallIds.has(captured.callId)) {
|
|
1439
|
+
rejectControlPlane(new Error(`restored duplicate playbook call id ${captured.callId}`));
|
|
1440
|
+
}
|
|
1441
|
+
restoreMode = {
|
|
1442
|
+
...(captured === undefined ? {} : { call: captured }),
|
|
1443
|
+
state: 'armed',
|
|
1444
|
+
};
|
|
1445
|
+
},
|
|
1446
|
+
confirmRestore() {
|
|
1447
|
+
const mode = restoreMode;
|
|
1448
|
+
if (!mode) {
|
|
1449
|
+
throw new Error('nested playbook bridge restore is not prepared');
|
|
1450
|
+
}
|
|
1451
|
+
if (mode.state === 'failed') {
|
|
1452
|
+
restoreMode = undefined;
|
|
1453
|
+
throw mode.error;
|
|
1454
|
+
}
|
|
1455
|
+
if (mode.call === undefined) {
|
|
1456
|
+
restoreMode = undefined;
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (mode.state !== 'claimed' || !mode.active) {
|
|
1460
|
+
const error = new Error(`restored playbook call ${mode.call.callId} was not claimed by actor startup`);
|
|
1461
|
+
restoreMode = undefined;
|
|
1462
|
+
reportControlPlaneError(error);
|
|
1463
|
+
throw error;
|
|
1464
|
+
}
|
|
1465
|
+
const active = mode.active;
|
|
1466
|
+
if (active.signal.aborted) {
|
|
1467
|
+
const error = active.signal.reason ??
|
|
1468
|
+
new Error('Restored nested playbook invocation aborted');
|
|
1469
|
+
rollbackRestoredCall(mode, error);
|
|
1470
|
+
restoreMode = undefined;
|
|
1471
|
+
throw error;
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
detachAbortListener(active);
|
|
1475
|
+
active.phase = 'suspended';
|
|
1476
|
+
restoreMode = undefined;
|
|
1477
|
+
publishSuspendedCall(active);
|
|
1478
|
+
}
|
|
1479
|
+
catch (error) {
|
|
1480
|
+
rollbackRestoredCall(mode, error);
|
|
1481
|
+
restoreMode = undefined;
|
|
1482
|
+
reportControlPlaneError(error);
|
|
1483
|
+
throw error;
|
|
1484
|
+
}
|
|
1485
|
+
},
|
|
1185
1486
|
subscribePendingCall(listener) {
|
|
1186
1487
|
if (disposed)
|
|
1187
1488
|
return () => undefined;
|
|
@@ -1237,6 +1538,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1237
1538
|
}
|
|
1238
1539
|
}
|
|
1239
1540
|
finally {
|
|
1541
|
+
restoreMode = undefined;
|
|
1240
1542
|
pendingListeners.clear();
|
|
1241
1543
|
}
|
|
1242
1544
|
},
|