@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
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
|
|
|
@@ -342,6 +343,49 @@ function capturedPort<K extends keyof PlaybookSession['ports']>(
|
|
|
342
343
|
return value as PlaybookSession['ports'][K];
|
|
343
344
|
}
|
|
344
345
|
|
|
346
|
+
function capturedSessionStore(
|
|
347
|
+
descriptors: PropertyDescriptorMap,
|
|
348
|
+
): PlaybookSession['playerSessions'] {
|
|
349
|
+
const captured = capturedDataValue(
|
|
350
|
+
descriptors,
|
|
351
|
+
'playerSessions',
|
|
352
|
+
'playbook session playerSessions',
|
|
353
|
+
);
|
|
354
|
+
if (!isRecord(captured)) {
|
|
355
|
+
throw new TypeError('playbook session playerSessions must be an object');
|
|
356
|
+
}
|
|
357
|
+
const storeDescriptors = Object.getOwnPropertyDescriptors(captured);
|
|
358
|
+
const method = (
|
|
359
|
+
name: keyof NonNullable<PlaybookSession['playerSessions']>,
|
|
360
|
+
): ((...args: never[]) => unknown) => {
|
|
361
|
+
const value = capturedDataValue(
|
|
362
|
+
storeDescriptors,
|
|
363
|
+
name,
|
|
364
|
+
`playbook session playerSessions.${name}`,
|
|
365
|
+
);
|
|
366
|
+
if (typeof value !== 'function') {
|
|
367
|
+
throw new TypeError(
|
|
368
|
+
`playbook session playerSessions.${name} must be a function`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return value as (...args: never[]) => unknown;
|
|
372
|
+
};
|
|
373
|
+
return Object.freeze({
|
|
374
|
+
select: method('select') as NonNullable<
|
|
375
|
+
PlaybookSession['playerSessions']
|
|
376
|
+
>['select'],
|
|
377
|
+
update: method('update') as NonNullable<
|
|
378
|
+
PlaybookSession['playerSessions']
|
|
379
|
+
>['update'],
|
|
380
|
+
snapshot: method('snapshot') as NonNullable<
|
|
381
|
+
PlaybookSession['playerSessions']
|
|
382
|
+
>['snapshot'],
|
|
383
|
+
restore: method('restore') as NonNullable<
|
|
384
|
+
PlaybookSession['playerSessions']
|
|
385
|
+
>['restore'],
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
345
389
|
/** Validate session causality and detach its immutable identity from the host. */
|
|
346
390
|
export function snapshotPlaybookSession(
|
|
347
391
|
session: PlaybookSession,
|
|
@@ -405,6 +449,10 @@ export function snapshotPlaybookSession(
|
|
|
405
449
|
sessionDescriptors,
|
|
406
450
|
'parentCallId',
|
|
407
451
|
);
|
|
452
|
+
const hasPlayerSessions = Object.prototype.hasOwnProperty.call(
|
|
453
|
+
sessionDescriptors,
|
|
454
|
+
'playerSessions',
|
|
455
|
+
);
|
|
408
456
|
let parentSessionId: string | undefined;
|
|
409
457
|
let parentCallId: string | undefined;
|
|
410
458
|
if (depth === 0) {
|
|
@@ -450,6 +498,9 @@ export function snapshotPlaybookSession(
|
|
|
450
498
|
emitStatus: capturedPort(portDescriptors, 'emitStatus'),
|
|
451
499
|
emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
|
|
452
500
|
});
|
|
501
|
+
const playerSessions = hasPlayerSessions
|
|
502
|
+
? capturedSessionStore(sessionDescriptors)
|
|
503
|
+
: undefined;
|
|
453
504
|
return Object.freeze({
|
|
454
505
|
sessionId,
|
|
455
506
|
playbookId,
|
|
@@ -457,6 +508,7 @@ export function snapshotPlaybookSession(
|
|
|
457
508
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
458
509
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
459
510
|
depth,
|
|
511
|
+
...(playerSessions === undefined ? {} : { playerSessions }),
|
|
460
512
|
ports,
|
|
461
513
|
});
|
|
462
514
|
}
|
|
@@ -706,23 +758,122 @@ const SNAPSHOT_SEQUENCE_KEYS = [
|
|
|
706
758
|
'playbookCall',
|
|
707
759
|
] as const;
|
|
708
760
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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.
|
|
712
811
|
export function assertPlaybookRuntimeSnapshot(
|
|
713
812
|
value: unknown,
|
|
714
813
|
expectedPlaybookId: string,
|
|
814
|
+
options: PlaybookRuntimeSnapshotValidationOptions = {},
|
|
715
815
|
): PlaybookRuntimeSnapshot {
|
|
716
|
-
|
|
816
|
+
const snapshot = snapshotJsonValue(value, 'runtime snapshot');
|
|
817
|
+
if (!isRecord(snapshot)) {
|
|
717
818
|
throw new TypeError('runtime snapshot must be an object');
|
|
718
819
|
}
|
|
719
|
-
|
|
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) {
|
|
720
842
|
throw new TypeError(
|
|
721
|
-
`runtime snapshot schemaVersion ${String(
|
|
843
|
+
`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1 or 2)`,
|
|
722
844
|
);
|
|
723
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')) {
|
|
862
|
+
throw new TypeError(
|
|
863
|
+
'runtime snapshot schemaVersion 1 must not carry suspendedCall',
|
|
864
|
+
);
|
|
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
|
+
}
|
|
724
875
|
const playbookId = requireNonEmptyString(
|
|
725
|
-
|
|
876
|
+
snapshot.playbookId,
|
|
726
877
|
'runtime snapshot playbookId',
|
|
727
878
|
);
|
|
728
879
|
if (playbookId !== expectedPlaybookId) {
|
|
@@ -730,17 +881,17 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
730
881
|
`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`,
|
|
731
882
|
);
|
|
732
883
|
}
|
|
733
|
-
if (!isRecord(
|
|
884
|
+
if (!isRecord(snapshot.machine)) {
|
|
734
885
|
throw new TypeError('runtime snapshot machine must be an object');
|
|
735
886
|
}
|
|
736
|
-
const machine =
|
|
737
|
-
if (!isRecord(
|
|
887
|
+
const machine = snapshot.machine;
|
|
888
|
+
if (!isRecord(snapshot.playerResumeTokens)) {
|
|
738
889
|
throw new TypeError(
|
|
739
890
|
'runtime snapshot playerResumeTokens must be an object',
|
|
740
891
|
);
|
|
741
892
|
}
|
|
742
893
|
const playerResumeTokens: Record<string, string> = {};
|
|
743
|
-
for (const [playerId, token] of Object.entries(
|
|
894
|
+
for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
|
|
744
895
|
defineEnumerableDataProperty(
|
|
745
896
|
playerResumeTokens,
|
|
746
897
|
playerId,
|
|
@@ -750,12 +901,17 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
750
901
|
),
|
|
751
902
|
);
|
|
752
903
|
}
|
|
753
|
-
if (!isRecord(
|
|
904
|
+
if (!isRecord(snapshot.sequences)) {
|
|
754
905
|
throw new TypeError('runtime snapshot sequences must be an object');
|
|
755
906
|
}
|
|
907
|
+
rejectUnknownKeys(
|
|
908
|
+
snapshot.sequences,
|
|
909
|
+
[...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'],
|
|
910
|
+
'runtime snapshot sequences',
|
|
911
|
+
);
|
|
756
912
|
const sequences = {} as PlaybookRuntimeSnapshot['sequences'];
|
|
757
913
|
for (const key of SNAPSHOT_SEQUENCE_KEYS) {
|
|
758
|
-
const sequence =
|
|
914
|
+
const sequence = snapshot.sequences[key];
|
|
759
915
|
if (!Number.isSafeInteger(sequence) || (sequence as number) < 0) {
|
|
760
916
|
throw new TypeError(
|
|
761
917
|
`runtime snapshot sequences.${key} must be a non-negative integer`,
|
|
@@ -763,7 +919,7 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
763
919
|
}
|
|
764
920
|
sequences[key] = sequence as number;
|
|
765
921
|
}
|
|
766
|
-
const captainCall =
|
|
922
|
+
const captainCall = snapshot.sequences.captainCall;
|
|
767
923
|
if (captainCall !== undefined) {
|
|
768
924
|
if (!Number.isSafeInteger(captainCall) || (captainCall as number) < 0) {
|
|
769
925
|
throw new TypeError(
|
|
@@ -772,20 +928,57 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
772
928
|
}
|
|
773
929
|
sequences.captainCall = captainCall as number;
|
|
774
930
|
}
|
|
775
|
-
validateState(
|
|
776
|
-
const state =
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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)) {
|
|
781
969
|
throw new TypeError(
|
|
782
970
|
'runtime snapshot pendingBossQuestions must be an array',
|
|
783
971
|
);
|
|
784
972
|
}
|
|
785
|
-
const pendingBossQuestions =
|
|
973
|
+
const pendingBossQuestions = snapshot.pendingBossQuestions.map(
|
|
786
974
|
(entry, index) => {
|
|
787
975
|
const path = `runtime snapshot pendingBossQuestions[${index}]`;
|
|
788
976
|
if (!isRecord(entry)) throw new TypeError(`${path} must be an object`);
|
|
977
|
+
rejectUnknownKeys(
|
|
978
|
+
entry,
|
|
979
|
+
['questionId', 'player', 'question', 'sourceItem'],
|
|
980
|
+
path,
|
|
981
|
+
);
|
|
789
982
|
const question: PlaybookPendingBossQuestion = {
|
|
790
983
|
questionId: requireNonEmptyString(
|
|
791
984
|
entry.questionId,
|
|
@@ -805,14 +998,21 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
805
998
|
return Object.freeze(question);
|
|
806
999
|
},
|
|
807
1000
|
);
|
|
808
|
-
|
|
809
|
-
schemaVersion: 1,
|
|
1001
|
+
const fields = {
|
|
810
1002
|
playbookId,
|
|
811
1003
|
machine,
|
|
812
1004
|
playerResumeTokens: Object.freeze(playerResumeTokens),
|
|
813
1005
|
sequences: Object.freeze(sequences),
|
|
814
1006
|
state,
|
|
815
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 }),
|
|
816
1016
|
});
|
|
817
1017
|
}
|
|
818
1018
|
|
|
@@ -865,15 +1065,24 @@ export class NestedPlaybookCallError extends Error {
|
|
|
865
1065
|
interface ActiveCall {
|
|
866
1066
|
readonly callId: string;
|
|
867
1067
|
readonly input: NestedPlaybookInput;
|
|
1068
|
+
readonly turnId?: number;
|
|
868
1069
|
readonly deferred: Deferred<JsonValue | undefined>;
|
|
869
1070
|
readonly finished: Deferred<void>;
|
|
870
1071
|
readonly controller: AbortController;
|
|
871
1072
|
readonly signal: AbortSignal;
|
|
872
|
-
phase: 'starting' | 'suspended' | 'settling';
|
|
1073
|
+
phase: 'starting' | 'restoring' | 'suspended' | 'settling';
|
|
873
1074
|
childSessionId?: string;
|
|
874
1075
|
abortListener?: () => void;
|
|
875
1076
|
settlement?: Promise<void>;
|
|
876
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;
|
|
877
1086
|
}
|
|
878
1087
|
|
|
879
1088
|
export interface PendingCallObserver {
|
|
@@ -887,6 +1096,15 @@ export interface NestedPlaybookBridge<
|
|
|
887
1096
|
TInput extends NestedPlaybookInput = NestedPlaybookInput,
|
|
888
1097
|
> extends PendingCallObserver {
|
|
889
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;
|
|
890
1108
|
resume(input: {
|
|
891
1109
|
callId: string;
|
|
892
1110
|
result: PlaybookCallResult;
|
|
@@ -1216,6 +1434,7 @@ export function createNestedPlaybookBridge<
|
|
|
1216
1434
|
TInput extends NestedPlaybookInput = NestedPlaybookInput,
|
|
1217
1435
|
>(options: NestedPlaybookBridgeOptions): NestedPlaybookBridge<TInput> {
|
|
1218
1436
|
let current: ActiveCall | undefined;
|
|
1437
|
+
let restoreMode: NestedPlaybookRestoreMode | undefined;
|
|
1219
1438
|
let disposed = false;
|
|
1220
1439
|
const usedCallIds = new Set<string>();
|
|
1221
1440
|
const pendingListeners = new Set<
|
|
@@ -1256,10 +1475,38 @@ export function createNestedPlaybookBridge<
|
|
|
1256
1475
|
}
|
|
1257
1476
|
: undefined;
|
|
1258
1477
|
|
|
1259
|
-
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 => {
|
|
1260
1502
|
if (active.abortListener) {
|
|
1261
1503
|
active.signal.removeEventListener('abort', active.abortListener);
|
|
1504
|
+
active.abortListener = undefined;
|
|
1262
1505
|
}
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
const clear = (active: ActiveCall): void => {
|
|
1509
|
+
detachAbortListener(active);
|
|
1263
1510
|
if (current === active) current = undefined;
|
|
1264
1511
|
};
|
|
1265
1512
|
|
|
@@ -1392,35 +1639,202 @@ export function createNestedPlaybookBridge<
|
|
|
1392
1639
|
}
|
|
1393
1640
|
};
|
|
1394
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
|
+
|
|
1395
1698
|
const actorLogic = fromPromise<JsonValue | undefined, TInput>(
|
|
1396
1699
|
async ({ input, signal: invocationSignal }) => {
|
|
1397
1700
|
if (disposed) {
|
|
1398
1701
|
rejectControlPlane(new Error('nested playbook bridge is disposed'));
|
|
1399
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
|
+
|
|
1400
1826
|
if (current) {
|
|
1401
1827
|
rejectControlPlane(
|
|
1402
1828
|
new Error(`playbook call ${current.callId} is already outstanding`),
|
|
1403
1829
|
);
|
|
1404
1830
|
}
|
|
1405
|
-
|
|
1831
|
+
|
|
1832
|
+
const callId = (() => {
|
|
1406
1833
|
try {
|
|
1407
|
-
return
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
'playbook input stateId',
|
|
1412
|
-
),
|
|
1413
|
-
playbookId: requireNonEmptyString(
|
|
1414
|
-
input.playbookId,
|
|
1415
|
-
'playbook input playbookId',
|
|
1416
|
-
),
|
|
1417
|
-
text: requireNonEmptyString(input.text, 'playbook input text'),
|
|
1418
|
-
},
|
|
1419
|
-
requireNonEmptyString(
|
|
1420
|
-
options.nextCallId(),
|
|
1421
|
-
'allocated playbook call id',
|
|
1422
|
-
),
|
|
1423
|
-
] as const;
|
|
1834
|
+
return requireNonEmptyString(
|
|
1835
|
+
options.nextCallId(),
|
|
1836
|
+
'allocated playbook call id',
|
|
1837
|
+
);
|
|
1424
1838
|
} catch (error) {
|
|
1425
1839
|
return rejectControlPlane(error);
|
|
1426
1840
|
}
|
|
@@ -1614,34 +2028,7 @@ export function createNestedPlaybookBridge<
|
|
|
1614
2028
|
|
|
1615
2029
|
active.phase = 'suspended';
|
|
1616
2030
|
active.childSessionId = start.childSessionId;
|
|
1617
|
-
|
|
1618
|
-
if (active.phase !== 'suspended') return;
|
|
1619
|
-
const result = resultFromThrown(
|
|
1620
|
-
active.input.playbookId,
|
|
1621
|
-
active.childSessionId,
|
|
1622
|
-
active.signal.reason ??
|
|
1623
|
-
new Error('Nested playbook invocation aborted'),
|
|
1624
|
-
true,
|
|
1625
|
-
);
|
|
1626
|
-
void settlePending(active, result).catch((error: unknown) => {
|
|
1627
|
-
reportBackgroundError(error);
|
|
1628
|
-
});
|
|
1629
|
-
};
|
|
1630
|
-
active.abortListener = abortListener;
|
|
1631
|
-
active.signal.addEventListener('abort', abortListener, { once: true });
|
|
1632
|
-
const pendingCall = pendingIdentity(active);
|
|
1633
|
-
if (!pendingCall) {
|
|
1634
|
-
throw new Error('suspended call identity was not recorded');
|
|
1635
|
-
}
|
|
1636
|
-
for (const listener of pendingListeners) {
|
|
1637
|
-
try {
|
|
1638
|
-
listener(pendingCall);
|
|
1639
|
-
} catch (error) {
|
|
1640
|
-
reportBackgroundError(error);
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
if (active.signal.aborted) abortListener();
|
|
1644
|
-
return await active.deferred.promise;
|
|
2031
|
+
return await waitOnSuspendedCall(active);
|
|
1645
2032
|
} catch (error) {
|
|
1646
2033
|
active.runError = error;
|
|
1647
2034
|
throw error;
|
|
@@ -1654,6 +2041,16 @@ export function createNestedPlaybookBridge<
|
|
|
1654
2041
|
const abortPending = async (
|
|
1655
2042
|
error: unknown = new Error('Nested playbook call aborted'),
|
|
1656
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
|
+
}
|
|
1657
2054
|
const active = current;
|
|
1658
2055
|
if (!active) return;
|
|
1659
2056
|
if (!active.controller.signal.aborted) active.controller.abort(error);
|
|
@@ -1689,6 +2086,79 @@ export function createNestedPlaybookBridge<
|
|
|
1689
2086
|
return {
|
|
1690
2087
|
actorLogic,
|
|
1691
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
|
+
},
|
|
1692
2162
|
subscribePendingCall(listener) {
|
|
1693
2163
|
if (disposed) return () => undefined;
|
|
1694
2164
|
pendingListeners.add(listener);
|
|
@@ -1756,6 +2226,7 @@ export function createNestedPlaybookBridge<
|
|
|
1756
2226
|
throw active.runError;
|
|
1757
2227
|
}
|
|
1758
2228
|
} finally {
|
|
2229
|
+
restoreMode = undefined;
|
|
1759
2230
|
pendingListeners.clear();
|
|
1760
2231
|
}
|
|
1761
2232
|
},
|