@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.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
PlaybookSession,
|
|
23
23
|
PlaybookState,
|
|
24
24
|
PlaybookStateValue,
|
|
25
|
+
PlaybookSuspendedCall,
|
|
25
26
|
PlayerResult,
|
|
26
27
|
} from './runtime.js';
|
|
27
28
|
|
|
@@ -757,23 +758,122 @@ const SNAPSHOT_SEQUENCE_KEYS = [
|
|
|
757
758
|
'playbookCall',
|
|
758
759
|
] as const;
|
|
759
760
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
761
|
+
export interface PlaybookRuntimeSnapshotValidationOptions {
|
|
762
|
+
/**
|
|
763
|
+
* Opt in only when the restore path will prepare and confirm the suspended
|
|
764
|
+
* call transaction. The default is fail-closed so a legacy restore cannot
|
|
765
|
+
* reopen or ignore it.
|
|
766
|
+
*/
|
|
767
|
+
allowSuspendedCall?: boolean;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function snapshotSuspendedCall(
|
|
771
|
+
value: unknown,
|
|
772
|
+
path = 'runtime snapshot suspendedCall',
|
|
773
|
+
): PlaybookSuspendedCall {
|
|
774
|
+
const captured = snapshotJsonValue(value, path);
|
|
775
|
+
if (!isRecord(captured)) {
|
|
776
|
+
throw new TypeError(`${path} must be an object`);
|
|
777
|
+
}
|
|
778
|
+
rejectUnknownKeys(
|
|
779
|
+
captured,
|
|
780
|
+
['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'],
|
|
781
|
+
path,
|
|
782
|
+
);
|
|
783
|
+
const call: PlaybookSuspendedCall = {
|
|
784
|
+
callId: requireNonEmptyString(captured.callId, `${path}.callId`),
|
|
785
|
+
stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
|
|
786
|
+
playbookId: requireNonEmptyString(
|
|
787
|
+
captured.playbookId,
|
|
788
|
+
`${path}.playbookId`,
|
|
789
|
+
),
|
|
790
|
+
text: requireNonEmptyString(captured.text, `${path}.text`),
|
|
791
|
+
childSessionId: requireNonEmptyString(
|
|
792
|
+
captured.childSessionId,
|
|
793
|
+
`${path}.childSessionId`,
|
|
794
|
+
),
|
|
795
|
+
};
|
|
796
|
+
if (own(captured, 'turnId')) {
|
|
797
|
+
if (
|
|
798
|
+
!Number.isSafeInteger(captured.turnId) ||
|
|
799
|
+
(captured.turnId as number) <= 0
|
|
800
|
+
) {
|
|
801
|
+
throw new TypeError(`${path}.turnId must be a positive integer`);
|
|
802
|
+
}
|
|
803
|
+
call.turnId = captured.turnId as number;
|
|
804
|
+
}
|
|
805
|
+
return Object.freeze(call);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// DR-014 §1 / DR-031 §5: validate and detach a host-supplied runtime
|
|
809
|
+
// snapshot before restore touches any state. A suspended schema-2 call is
|
|
810
|
+
// rejected unless the restore path explicitly promises to seed and claim it.
|
|
763
811
|
export function assertPlaybookRuntimeSnapshot(
|
|
764
812
|
value: unknown,
|
|
765
813
|
expectedPlaybookId: string,
|
|
814
|
+
options: PlaybookRuntimeSnapshotValidationOptions = {},
|
|
766
815
|
): PlaybookRuntimeSnapshot {
|
|
767
|
-
|
|
816
|
+
const snapshot = snapshotJsonValue(value, 'runtime snapshot');
|
|
817
|
+
if (!isRecord(snapshot)) {
|
|
768
818
|
throw new TypeError('runtime snapshot must be an object');
|
|
769
819
|
}
|
|
770
|
-
|
|
820
|
+
const capturedOptions = snapshotJsonValue(
|
|
821
|
+
options,
|
|
822
|
+
'runtime snapshot validation options',
|
|
823
|
+
);
|
|
824
|
+
if (!isRecord(capturedOptions)) {
|
|
825
|
+
throw new TypeError('runtime snapshot validation options must be an object');
|
|
826
|
+
}
|
|
827
|
+
rejectUnknownKeys(
|
|
828
|
+
capturedOptions,
|
|
829
|
+
['allowSuspendedCall'],
|
|
830
|
+
'runtime snapshot validation options',
|
|
831
|
+
);
|
|
832
|
+
if (
|
|
833
|
+
capturedOptions.allowSuspendedCall !== undefined &&
|
|
834
|
+
typeof capturedOptions.allowSuspendedCall !== 'boolean'
|
|
835
|
+
) {
|
|
836
|
+
throw new TypeError(
|
|
837
|
+
'runtime snapshot validation options.allowSuspendedCall must be boolean',
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
|
|
841
|
+
if (snapshot.schemaVersion !== 1 && snapshot.schemaVersion !== 2) {
|
|
842
|
+
throw new TypeError(
|
|
843
|
+
`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1 or 2)`,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
const schemaVersion = snapshot.schemaVersion;
|
|
847
|
+
rejectUnknownKeys(
|
|
848
|
+
snapshot,
|
|
849
|
+
[
|
|
850
|
+
'schemaVersion',
|
|
851
|
+
'playbookId',
|
|
852
|
+
'machine',
|
|
853
|
+
'playerResumeTokens',
|
|
854
|
+
'sequences',
|
|
855
|
+
'state',
|
|
856
|
+
'pendingBossQuestions',
|
|
857
|
+
'suspendedCall',
|
|
858
|
+
],
|
|
859
|
+
'runtime snapshot',
|
|
860
|
+
);
|
|
861
|
+
if (schemaVersion === 1 && own(snapshot, 'suspendedCall')) {
|
|
771
862
|
throw new TypeError(
|
|
772
|
-
|
|
863
|
+
'runtime snapshot schemaVersion 1 must not carry suspendedCall',
|
|
773
864
|
);
|
|
774
865
|
}
|
|
866
|
+
let suspendedCall: PlaybookSuspendedCall | undefined;
|
|
867
|
+
if (schemaVersion === 2 && own(snapshot, 'suspendedCall')) {
|
|
868
|
+
suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
|
|
869
|
+
if (!allowSuspendedCall) {
|
|
870
|
+
throw new TypeError(
|
|
871
|
+
'runtime snapshot suspendedCall requires a restore path that explicitly allows it',
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
775
875
|
const playbookId = requireNonEmptyString(
|
|
776
|
-
|
|
876
|
+
snapshot.playbookId,
|
|
777
877
|
'runtime snapshot playbookId',
|
|
778
878
|
);
|
|
779
879
|
if (playbookId !== expectedPlaybookId) {
|
|
@@ -781,17 +881,17 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
781
881
|
`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`,
|
|
782
882
|
);
|
|
783
883
|
}
|
|
784
|
-
if (!isRecord(
|
|
884
|
+
if (!isRecord(snapshot.machine)) {
|
|
785
885
|
throw new TypeError('runtime snapshot machine must be an object');
|
|
786
886
|
}
|
|
787
|
-
const machine =
|
|
788
|
-
if (!isRecord(
|
|
887
|
+
const machine = snapshot.machine;
|
|
888
|
+
if (!isRecord(snapshot.playerResumeTokens)) {
|
|
789
889
|
throw new TypeError(
|
|
790
890
|
'runtime snapshot playerResumeTokens must be an object',
|
|
791
891
|
);
|
|
792
892
|
}
|
|
793
893
|
const playerResumeTokens: Record<string, string> = {};
|
|
794
|
-
for (const [playerId, token] of Object.entries(
|
|
894
|
+
for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
|
|
795
895
|
defineEnumerableDataProperty(
|
|
796
896
|
playerResumeTokens,
|
|
797
897
|
playerId,
|
|
@@ -801,12 +901,17 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
801
901
|
),
|
|
802
902
|
);
|
|
803
903
|
}
|
|
804
|
-
if (!isRecord(
|
|
904
|
+
if (!isRecord(snapshot.sequences)) {
|
|
805
905
|
throw new TypeError('runtime snapshot sequences must be an object');
|
|
806
906
|
}
|
|
907
|
+
rejectUnknownKeys(
|
|
908
|
+
snapshot.sequences,
|
|
909
|
+
[...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'],
|
|
910
|
+
'runtime snapshot sequences',
|
|
911
|
+
);
|
|
807
912
|
const sequences = {} as PlaybookRuntimeSnapshot['sequences'];
|
|
808
913
|
for (const key of SNAPSHOT_SEQUENCE_KEYS) {
|
|
809
|
-
const sequence =
|
|
914
|
+
const sequence = snapshot.sequences[key];
|
|
810
915
|
if (!Number.isSafeInteger(sequence) || (sequence as number) < 0) {
|
|
811
916
|
throw new TypeError(
|
|
812
917
|
`runtime snapshot sequences.${key} must be a non-negative integer`,
|
|
@@ -814,7 +919,7 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
814
919
|
}
|
|
815
920
|
sequences[key] = sequence as number;
|
|
816
921
|
}
|
|
817
|
-
const captainCall =
|
|
922
|
+
const captainCall = snapshot.sequences.captainCall;
|
|
818
923
|
if (captainCall !== undefined) {
|
|
819
924
|
if (!Number.isSafeInteger(captainCall) || (captainCall as number) < 0) {
|
|
820
925
|
throw new TypeError(
|
|
@@ -823,20 +928,57 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
823
928
|
}
|
|
824
929
|
sequences.captainCall = captainCall as number;
|
|
825
930
|
}
|
|
826
|
-
validateState(
|
|
827
|
-
const state =
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
931
|
+
validateState(snapshot.state, 'runtime snapshot state');
|
|
932
|
+
const state = snapshot.state as unknown as PlaybookState;
|
|
933
|
+
if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
|
|
934
|
+
throw new TypeError(
|
|
935
|
+
`runtime snapshot state tagged ${SUSPENDED_TAG} requires schemaVersion 2 suspendedCall`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
if (suspendedCall) {
|
|
939
|
+
if (sequences.playbookCall === 0) {
|
|
940
|
+
throw new TypeError(
|
|
941
|
+
'runtime snapshot suspendedCall requires sequences.playbookCall greater than zero',
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
if (
|
|
945
|
+
suspendedCall.turnId !== undefined &&
|
|
946
|
+
suspendedCall.turnId > sequences.turn
|
|
947
|
+
) {
|
|
948
|
+
throw new TypeError(
|
|
949
|
+
'runtime snapshot suspendedCall.turnId must not exceed sequences.turn',
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
if (state.status !== 'active' || !state.quiescent) {
|
|
953
|
+
throw new TypeError(
|
|
954
|
+
'runtime snapshot suspendedCall requires an active quiescent state',
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
if (!state.tags.includes(SUSPENDED_TAG)) {
|
|
958
|
+
throw new TypeError(
|
|
959
|
+
`runtime snapshot suspendedCall requires state tag ${SUSPENDED_TAG}`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
if (!state.activeStateIds.includes(suspendedCall.stateId)) {
|
|
963
|
+
throw new TypeError(
|
|
964
|
+
'runtime snapshot suspendedCall.stateId must be active in snapshot state',
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (!Array.isArray(snapshot.pendingBossQuestions)) {
|
|
832
969
|
throw new TypeError(
|
|
833
970
|
'runtime snapshot pendingBossQuestions must be an array',
|
|
834
971
|
);
|
|
835
972
|
}
|
|
836
|
-
const pendingBossQuestions =
|
|
973
|
+
const pendingBossQuestions = snapshot.pendingBossQuestions.map(
|
|
837
974
|
(entry, index) => {
|
|
838
975
|
const path = `runtime snapshot pendingBossQuestions[${index}]`;
|
|
839
976
|
if (!isRecord(entry)) throw new TypeError(`${path} must be an object`);
|
|
977
|
+
rejectUnknownKeys(
|
|
978
|
+
entry,
|
|
979
|
+
['questionId', 'player', 'question', 'sourceItem'],
|
|
980
|
+
path,
|
|
981
|
+
);
|
|
840
982
|
const question: PlaybookPendingBossQuestion = {
|
|
841
983
|
questionId: requireNonEmptyString(
|
|
842
984
|
entry.questionId,
|
|
@@ -856,14 +998,21 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
856
998
|
return Object.freeze(question);
|
|
857
999
|
},
|
|
858
1000
|
);
|
|
859
|
-
|
|
860
|
-
schemaVersion: 1,
|
|
1001
|
+
const fields = {
|
|
861
1002
|
playbookId,
|
|
862
1003
|
machine,
|
|
863
1004
|
playerResumeTokens: Object.freeze(playerResumeTokens),
|
|
864
1005
|
sequences: Object.freeze(sequences),
|
|
865
1006
|
state,
|
|
866
1007
|
pendingBossQuestions: Object.freeze(pendingBossQuestions),
|
|
1008
|
+
};
|
|
1009
|
+
if (schemaVersion === 1) {
|
|
1010
|
+
return Object.freeze({ schemaVersion: 1, ...fields });
|
|
1011
|
+
}
|
|
1012
|
+
return Object.freeze({
|
|
1013
|
+
schemaVersion: 2,
|
|
1014
|
+
...fields,
|
|
1015
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
867
1016
|
});
|
|
868
1017
|
}
|
|
869
1018
|
|
|
@@ -916,15 +1065,24 @@ export class NestedPlaybookCallError extends Error {
|
|
|
916
1065
|
interface ActiveCall {
|
|
917
1066
|
readonly callId: string;
|
|
918
1067
|
readonly input: NestedPlaybookInput;
|
|
1068
|
+
readonly turnId?: number;
|
|
919
1069
|
readonly deferred: Deferred<JsonValue | undefined>;
|
|
920
1070
|
readonly finished: Deferred<void>;
|
|
921
1071
|
readonly controller: AbortController;
|
|
922
1072
|
readonly signal: AbortSignal;
|
|
923
|
-
phase: 'starting' | 'suspended' | 'settling';
|
|
1073
|
+
phase: 'starting' | 'restoring' | 'suspended' | 'settling';
|
|
924
1074
|
childSessionId?: string;
|
|
925
1075
|
abortListener?: () => void;
|
|
926
1076
|
settlement?: Promise<void>;
|
|
927
1077
|
runError?: unknown;
|
|
1078
|
+
restoreRolledBack?: boolean;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
interface NestedPlaybookRestoreMode {
|
|
1082
|
+
readonly call?: PlaybookSuspendedCall;
|
|
1083
|
+
state: 'armed' | 'claimed' | 'failed';
|
|
1084
|
+
active?: ActiveCall;
|
|
1085
|
+
error?: unknown;
|
|
928
1086
|
}
|
|
929
1087
|
|
|
930
1088
|
export interface PendingCallObserver {
|
|
@@ -938,6 +1096,15 @@ export interface NestedPlaybookBridge<
|
|
|
938
1096
|
TInput extends NestedPlaybookInput = NestedPlaybookInput,
|
|
939
1097
|
> extends PendingCallObserver {
|
|
940
1098
|
actorLogic: PromiseActorLogic<JsonValue | undefined, TInput>;
|
|
1099
|
+
/** Arm fail-closed actor startup for a snapshot with zero or one nested call. */
|
|
1100
|
+
prepareRestore(call?: PlaybookSuspendedCall): void;
|
|
1101
|
+
/**
|
|
1102
|
+
* Commit restore startup after the persisted machine recreated exactly the
|
|
1103
|
+
* expected zero or one nested invocation.
|
|
1104
|
+
*/
|
|
1105
|
+
confirmRestore(): void;
|
|
1106
|
+
/** Complete durable identity; undefined until a normal or restored call suspends. */
|
|
1107
|
+
getSuspendedCall(): PlaybookSuspendedCall | undefined;
|
|
941
1108
|
resume(input: {
|
|
942
1109
|
callId: string;
|
|
943
1110
|
result: PlaybookCallResult;
|
|
@@ -1267,6 +1434,7 @@ export function createNestedPlaybookBridge<
|
|
|
1267
1434
|
TInput extends NestedPlaybookInput = NestedPlaybookInput,
|
|
1268
1435
|
>(options: NestedPlaybookBridgeOptions): NestedPlaybookBridge<TInput> {
|
|
1269
1436
|
let current: ActiveCall | undefined;
|
|
1437
|
+
let restoreMode: NestedPlaybookRestoreMode | undefined;
|
|
1270
1438
|
let disposed = false;
|
|
1271
1439
|
const usedCallIds = new Set<string>();
|
|
1272
1440
|
const pendingListeners = new Set<
|
|
@@ -1307,10 +1475,38 @@ export function createNestedPlaybookBridge<
|
|
|
1307
1475
|
}
|
|
1308
1476
|
: undefined;
|
|
1309
1477
|
|
|
1310
|
-
const
|
|
1478
|
+
const suspendedIdentity = (
|
|
1479
|
+
active: ActiveCall | undefined,
|
|
1480
|
+
): PlaybookSuspendedCall | undefined =>
|
|
1481
|
+
active?.phase === 'suspended' && active.childSessionId
|
|
1482
|
+
? Object.freeze({
|
|
1483
|
+
callId: active.callId,
|
|
1484
|
+
stateId: active.input.stateId,
|
|
1485
|
+
playbookId: active.input.playbookId,
|
|
1486
|
+
text: active.input.text,
|
|
1487
|
+
childSessionId: active.childSessionId,
|
|
1488
|
+
...(active.turnId === undefined ? {} : { turnId: active.turnId }),
|
|
1489
|
+
})
|
|
1490
|
+
: undefined;
|
|
1491
|
+
|
|
1492
|
+
const failRestoreMode = (
|
|
1493
|
+
mode: NestedPlaybookRestoreMode,
|
|
1494
|
+
error: unknown,
|
|
1495
|
+
): void => {
|
|
1496
|
+
mode.state = 'failed';
|
|
1497
|
+
mode.error = error;
|
|
1498
|
+
reportControlPlaneError(error);
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
const detachAbortListener = (active: ActiveCall): void => {
|
|
1311
1502
|
if (active.abortListener) {
|
|
1312
1503
|
active.signal.removeEventListener('abort', active.abortListener);
|
|
1504
|
+
active.abortListener = undefined;
|
|
1313
1505
|
}
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
const clear = (active: ActiveCall): void => {
|
|
1509
|
+
detachAbortListener(active);
|
|
1314
1510
|
if (current === active) current = undefined;
|
|
1315
1511
|
};
|
|
1316
1512
|
|
|
@@ -1443,35 +1639,202 @@ export function createNestedPlaybookBridge<
|
|
|
1443
1639
|
}
|
|
1444
1640
|
};
|
|
1445
1641
|
|
|
1642
|
+
const rollbackRestoredCall = (
|
|
1643
|
+
mode: NestedPlaybookRestoreMode,
|
|
1644
|
+
error: unknown,
|
|
1645
|
+
): ActiveCall | undefined => {
|
|
1646
|
+
const active = mode.active;
|
|
1647
|
+
mode.state = 'failed';
|
|
1648
|
+
mode.error = error;
|
|
1649
|
+
mode.active = undefined;
|
|
1650
|
+
if (!active) return undefined;
|
|
1651
|
+
active.phase = 'settling';
|
|
1652
|
+
active.restoreRolledBack = true;
|
|
1653
|
+
clear(active);
|
|
1654
|
+
usedCallIds.delete(active.callId);
|
|
1655
|
+
active.deferred.reject(error);
|
|
1656
|
+
return active;
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
const publishSuspendedCall = (active: ActiveCall): void => {
|
|
1660
|
+
if (active.phase !== 'suspended') {
|
|
1661
|
+
throw new Error(`playbook call ${active.callId} is not suspended`);
|
|
1662
|
+
}
|
|
1663
|
+
const abortListener = (): void => {
|
|
1664
|
+
if (active.phase !== 'suspended') return;
|
|
1665
|
+
const result = resultFromThrown(
|
|
1666
|
+
active.input.playbookId,
|
|
1667
|
+
active.childSessionId,
|
|
1668
|
+
active.signal.reason ?? new Error('Nested playbook invocation aborted'),
|
|
1669
|
+
true,
|
|
1670
|
+
);
|
|
1671
|
+
void settlePending(active, result).catch((error: unknown) => {
|
|
1672
|
+
reportBackgroundError(error);
|
|
1673
|
+
});
|
|
1674
|
+
};
|
|
1675
|
+
active.abortListener = abortListener;
|
|
1676
|
+
active.signal.addEventListener('abort', abortListener, { once: true });
|
|
1677
|
+
const pendingCall = pendingIdentity(active);
|
|
1678
|
+
if (!pendingCall) {
|
|
1679
|
+
throw new Error('suspended call identity was not recorded');
|
|
1680
|
+
}
|
|
1681
|
+
for (const listener of pendingListeners) {
|
|
1682
|
+
try {
|
|
1683
|
+
listener(pendingCall);
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
reportBackgroundError(error);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
if (active.signal.aborted) abortListener();
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
const waitOnSuspendedCall = async (
|
|
1692
|
+
active: ActiveCall,
|
|
1693
|
+
): Promise<JsonValue | undefined> => {
|
|
1694
|
+
publishSuspendedCall(active);
|
|
1695
|
+
return await active.deferred.promise;
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1446
1698
|
const actorLogic = fromPromise<JsonValue | undefined, TInput>(
|
|
1447
1699
|
async ({ input, signal: invocationSignal }) => {
|
|
1448
1700
|
if (disposed) {
|
|
1449
1701
|
rejectControlPlane(new Error('nested playbook bridge is disposed'));
|
|
1450
1702
|
}
|
|
1703
|
+
const normalizedInput = (() => {
|
|
1704
|
+
try {
|
|
1705
|
+
return {
|
|
1706
|
+
stateId: requireNonEmptyString(
|
|
1707
|
+
input.stateId,
|
|
1708
|
+
'playbook input stateId',
|
|
1709
|
+
),
|
|
1710
|
+
playbookId: requireNonEmptyString(
|
|
1711
|
+
input.playbookId,
|
|
1712
|
+
'playbook input playbookId',
|
|
1713
|
+
),
|
|
1714
|
+
text: requireNonEmptyString(input.text, 'playbook input text'),
|
|
1715
|
+
};
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
const mode = restoreMode;
|
|
1718
|
+
if (mode) {
|
|
1719
|
+
if (mode.state === 'claimed') {
|
|
1720
|
+
rollbackRestoredCall(mode, error);
|
|
1721
|
+
reportControlPlaneError(error);
|
|
1722
|
+
} else failRestoreMode(mode, error);
|
|
1723
|
+
throw error;
|
|
1724
|
+
}
|
|
1725
|
+
return rejectControlPlane(error);
|
|
1726
|
+
}
|
|
1727
|
+
})();
|
|
1728
|
+
|
|
1729
|
+
const mode = restoreMode;
|
|
1730
|
+
if (mode) {
|
|
1731
|
+
if (mode.state !== 'armed') {
|
|
1732
|
+
const callId = mode.call?.callId ?? 'without a descriptor';
|
|
1733
|
+
const error = new Error(
|
|
1734
|
+
mode.state === 'claimed'
|
|
1735
|
+
? `restored playbook call ${callId} was claimed more than once`
|
|
1736
|
+
: `restored playbook call ${callId} is no longer claimable`,
|
|
1737
|
+
);
|
|
1738
|
+
if (mode.state === 'claimed') rollbackRestoredCall(mode, error);
|
|
1739
|
+
else mode.error ??= error;
|
|
1740
|
+
reportControlPlaneError(error);
|
|
1741
|
+
throw error;
|
|
1742
|
+
}
|
|
1743
|
+
const seed = mode.call;
|
|
1744
|
+
if (!seed) {
|
|
1745
|
+
const error = new Error(
|
|
1746
|
+
'restored machine invoked a nested playbook without a suspendedCall descriptor',
|
|
1747
|
+
);
|
|
1748
|
+
failRestoreMode(mode, error);
|
|
1749
|
+
throw error;
|
|
1750
|
+
}
|
|
1751
|
+
for (const field of ['stateId', 'playbookId', 'text'] as const) {
|
|
1752
|
+
if (normalizedInput[field] !== seed[field]) {
|
|
1753
|
+
const error = new Error(
|
|
1754
|
+
`restored playbook call ${seed.callId} ${field} does not match its persisted input`,
|
|
1755
|
+
);
|
|
1756
|
+
failRestoreMode(mode, error);
|
|
1757
|
+
throw error;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
if (usedCallIds.has(seed.callId)) {
|
|
1761
|
+
const error = new Error(
|
|
1762
|
+
`restored duplicate playbook call id ${seed.callId}`,
|
|
1763
|
+
);
|
|
1764
|
+
failRestoreMode(mode, error);
|
|
1765
|
+
throw error;
|
|
1766
|
+
}
|
|
1767
|
+
const controller = new AbortController();
|
|
1768
|
+
let callSignal: AbortSignal;
|
|
1769
|
+
try {
|
|
1770
|
+
callSignal = combineAbortSignals(
|
|
1771
|
+
invocationSignal,
|
|
1772
|
+
options.getBoundarySignal?.(),
|
|
1773
|
+
controller.signal,
|
|
1774
|
+
);
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
failRestoreMode(mode, error);
|
|
1777
|
+
throw error;
|
|
1778
|
+
}
|
|
1779
|
+
const active: ActiveCall = {
|
|
1780
|
+
callId: seed.callId,
|
|
1781
|
+
input: normalizedInput,
|
|
1782
|
+
...(seed.turnId === undefined
|
|
1783
|
+
? {}
|
|
1784
|
+
: { turnId: seed.turnId }),
|
|
1785
|
+
deferred: deferred<JsonValue | undefined>(),
|
|
1786
|
+
finished: deferred<void>(),
|
|
1787
|
+
controller,
|
|
1788
|
+
signal: callSignal,
|
|
1789
|
+
phase: 'restoring',
|
|
1790
|
+
childSessionId: seed.childSessionId,
|
|
1791
|
+
};
|
|
1792
|
+
usedCallIds.add(active.callId);
|
|
1793
|
+
current = active;
|
|
1794
|
+
mode.state = 'claimed';
|
|
1795
|
+
mode.active = active;
|
|
1796
|
+
const restoreAbortListener = (): void => {
|
|
1797
|
+
if (
|
|
1798
|
+
restoreMode !== mode ||
|
|
1799
|
+
mode.state !== 'claimed' ||
|
|
1800
|
+
mode.active !== active ||
|
|
1801
|
+
active.phase !== 'restoring'
|
|
1802
|
+
) {
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
rollbackRestoredCall(
|
|
1806
|
+
mode,
|
|
1807
|
+
active.signal.reason ??
|
|
1808
|
+
new Error('Restored nested playbook invocation aborted'),
|
|
1809
|
+
);
|
|
1810
|
+
};
|
|
1811
|
+
active.abortListener = restoreAbortListener;
|
|
1812
|
+
active.signal.addEventListener('abort', restoreAbortListener, {
|
|
1813
|
+
once: true,
|
|
1814
|
+
});
|
|
1815
|
+
if (active.signal.aborted) restoreAbortListener();
|
|
1816
|
+
try {
|
|
1817
|
+
return await active.deferred.promise;
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
if (!active.restoreRolledBack) active.runError = error;
|
|
1820
|
+
throw error;
|
|
1821
|
+
} finally {
|
|
1822
|
+
active.finished.resolve(undefined);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1451
1826
|
if (current) {
|
|
1452
1827
|
rejectControlPlane(
|
|
1453
1828
|
new Error(`playbook call ${current.callId} is already outstanding`),
|
|
1454
1829
|
);
|
|
1455
1830
|
}
|
|
1456
|
-
|
|
1831
|
+
|
|
1832
|
+
const callId = (() => {
|
|
1457
1833
|
try {
|
|
1458
|
-
return
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
'playbook input stateId',
|
|
1463
|
-
),
|
|
1464
|
-
playbookId: requireNonEmptyString(
|
|
1465
|
-
input.playbookId,
|
|
1466
|
-
'playbook input playbookId',
|
|
1467
|
-
),
|
|
1468
|
-
text: requireNonEmptyString(input.text, 'playbook input text'),
|
|
1469
|
-
},
|
|
1470
|
-
requireNonEmptyString(
|
|
1471
|
-
options.nextCallId(),
|
|
1472
|
-
'allocated playbook call id',
|
|
1473
|
-
),
|
|
1474
|
-
] as const;
|
|
1834
|
+
return requireNonEmptyString(
|
|
1835
|
+
options.nextCallId(),
|
|
1836
|
+
'allocated playbook call id',
|
|
1837
|
+
);
|
|
1475
1838
|
} catch (error) {
|
|
1476
1839
|
return rejectControlPlane(error);
|
|
1477
1840
|
}
|
|
@@ -1665,34 +2028,7 @@ export function createNestedPlaybookBridge<
|
|
|
1665
2028
|
|
|
1666
2029
|
active.phase = 'suspended';
|
|
1667
2030
|
active.childSessionId = start.childSessionId;
|
|
1668
|
-
|
|
1669
|
-
if (active.phase !== 'suspended') return;
|
|
1670
|
-
const result = resultFromThrown(
|
|
1671
|
-
active.input.playbookId,
|
|
1672
|
-
active.childSessionId,
|
|
1673
|
-
active.signal.reason ??
|
|
1674
|
-
new Error('Nested playbook invocation aborted'),
|
|
1675
|
-
true,
|
|
1676
|
-
);
|
|
1677
|
-
void settlePending(active, result).catch((error: unknown) => {
|
|
1678
|
-
reportBackgroundError(error);
|
|
1679
|
-
});
|
|
1680
|
-
};
|
|
1681
|
-
active.abortListener = abortListener;
|
|
1682
|
-
active.signal.addEventListener('abort', abortListener, { once: true });
|
|
1683
|
-
const pendingCall = pendingIdentity(active);
|
|
1684
|
-
if (!pendingCall) {
|
|
1685
|
-
throw new Error('suspended call identity was not recorded');
|
|
1686
|
-
}
|
|
1687
|
-
for (const listener of pendingListeners) {
|
|
1688
|
-
try {
|
|
1689
|
-
listener(pendingCall);
|
|
1690
|
-
} catch (error) {
|
|
1691
|
-
reportBackgroundError(error);
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
if (active.signal.aborted) abortListener();
|
|
1695
|
-
return await active.deferred.promise;
|
|
2031
|
+
return await waitOnSuspendedCall(active);
|
|
1696
2032
|
} catch (error) {
|
|
1697
2033
|
active.runError = error;
|
|
1698
2034
|
throw error;
|
|
@@ -1705,6 +2041,16 @@ export function createNestedPlaybookBridge<
|
|
|
1705
2041
|
const abortPending = async (
|
|
1706
2042
|
error: unknown = new Error('Nested playbook call aborted'),
|
|
1707
2043
|
): Promise<void> => {
|
|
2044
|
+
const mode = restoreMode;
|
|
2045
|
+
if (mode) {
|
|
2046
|
+
restoreMode = undefined;
|
|
2047
|
+
const restored =
|
|
2048
|
+
mode.state === 'claimed'
|
|
2049
|
+
? rollbackRestoredCall(mode, error)
|
|
2050
|
+
: undefined;
|
|
2051
|
+
if (restored) await restored.finished.promise;
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
1708
2054
|
const active = current;
|
|
1709
2055
|
if (!active) return;
|
|
1710
2056
|
if (!active.controller.signal.aborted) active.controller.abort(error);
|
|
@@ -1740,6 +2086,79 @@ export function createNestedPlaybookBridge<
|
|
|
1740
2086
|
return {
|
|
1741
2087
|
actorLogic,
|
|
1742
2088
|
getPendingCall: () => pendingIdentity(current),
|
|
2089
|
+
getSuspendedCall: () => suspendedIdentity(current),
|
|
2090
|
+
prepareRestore(call) {
|
|
2091
|
+
// Capture the complete host-owned descriptor before observing or
|
|
2092
|
+
// mutating bridge state, so a rejected preparation cannot leave state.
|
|
2093
|
+
const captured =
|
|
2094
|
+
call === undefined
|
|
2095
|
+
? undefined
|
|
2096
|
+
: snapshotSuspendedCall(call, 'restored playbook call');
|
|
2097
|
+
if (disposed) {
|
|
2098
|
+
rejectControlPlane(new Error('nested playbook bridge is disposed'));
|
|
2099
|
+
}
|
|
2100
|
+
if (current) {
|
|
2101
|
+
rejectControlPlane(
|
|
2102
|
+
new Error(`playbook call ${current.callId} is already outstanding`),
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
if (restoreMode) {
|
|
2106
|
+
rejectControlPlane(
|
|
2107
|
+
new Error('nested playbook bridge restore is already prepared'),
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
if (captured && usedCallIds.has(captured.callId)) {
|
|
2111
|
+
rejectControlPlane(
|
|
2112
|
+
new Error(`restored duplicate playbook call id ${captured.callId}`),
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
restoreMode = {
|
|
2116
|
+
...(captured === undefined ? {} : { call: captured }),
|
|
2117
|
+
state: 'armed',
|
|
2118
|
+
};
|
|
2119
|
+
},
|
|
2120
|
+
confirmRestore() {
|
|
2121
|
+
const mode = restoreMode;
|
|
2122
|
+
if (!mode) {
|
|
2123
|
+
throw new Error('nested playbook bridge restore is not prepared');
|
|
2124
|
+
}
|
|
2125
|
+
if (mode.state === 'failed') {
|
|
2126
|
+
restoreMode = undefined;
|
|
2127
|
+
throw mode.error;
|
|
2128
|
+
}
|
|
2129
|
+
if (mode.call === undefined) {
|
|
2130
|
+
restoreMode = undefined;
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
if (mode.state !== 'claimed' || !mode.active) {
|
|
2134
|
+
const error = new Error(
|
|
2135
|
+
`restored playbook call ${mode.call.callId} was not claimed by actor startup`,
|
|
2136
|
+
);
|
|
2137
|
+
restoreMode = undefined;
|
|
2138
|
+
reportControlPlaneError(error);
|
|
2139
|
+
throw error;
|
|
2140
|
+
}
|
|
2141
|
+
const active = mode.active;
|
|
2142
|
+
if (active.signal.aborted) {
|
|
2143
|
+
const error =
|
|
2144
|
+
active.signal.reason ??
|
|
2145
|
+
new Error('Restored nested playbook invocation aborted');
|
|
2146
|
+
rollbackRestoredCall(mode, error);
|
|
2147
|
+
restoreMode = undefined;
|
|
2148
|
+
throw error;
|
|
2149
|
+
}
|
|
2150
|
+
try {
|
|
2151
|
+
detachAbortListener(active);
|
|
2152
|
+
active.phase = 'suspended';
|
|
2153
|
+
restoreMode = undefined;
|
|
2154
|
+
publishSuspendedCall(active);
|
|
2155
|
+
} catch (error) {
|
|
2156
|
+
rollbackRestoredCall(mode, error);
|
|
2157
|
+
restoreMode = undefined;
|
|
2158
|
+
reportControlPlaneError(error);
|
|
2159
|
+
throw error;
|
|
2160
|
+
}
|
|
2161
|
+
},
|
|
1743
2162
|
subscribePendingCall(listener) {
|
|
1744
2163
|
if (disposed) return () => undefined;
|
|
1745
2164
|
pendingListeners.add(listener);
|
|
@@ -1807,6 +2226,7 @@ export function createNestedPlaybookBridge<
|
|
|
1807
2226
|
throw active.runError;
|
|
1808
2227
|
}
|
|
1809
2228
|
} finally {
|
|
2229
|
+
restoreMode = undefined;
|
|
1810
2230
|
pendingListeners.clear();
|
|
1811
2231
|
}
|
|
1812
2232
|
},
|