@sublang/playbook 6.0.0 → 8.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.
Files changed (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. package/src/xstate-runtime.ts +566 -78
@@ -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
 
@@ -385,6 +386,50 @@ function capturedSessionStore(
385
386
  });
386
387
  }
387
388
 
389
+ function capturedRoleBindings(
390
+ descriptors: PropertyDescriptorMap,
391
+ ): NonNullable<PlaybookSession['roleBindings']> {
392
+ const captured = snapshotJsonValue(
393
+ capturedDataValue(
394
+ descriptors,
395
+ 'roleBindings',
396
+ 'playbook session roleBindings',
397
+ ),
398
+ 'playbook session roleBindings',
399
+ );
400
+ if (!isRecord(captured)) {
401
+ throw new TypeError('playbook session roleBindings must be an object');
402
+ }
403
+ const bindings: Record<
404
+ string,
405
+ { readonly playerId: string; readonly promptIdentity: string }
406
+ > = {};
407
+ for (const [roleId, value] of Object.entries(captured)) {
408
+ requireNonEmptyString(roleId, 'playbook session roleBindings role id');
409
+ if (!isRecord(value)) {
410
+ throw new TypeError(
411
+ `playbook session roleBindings.${roleId} must be an object`,
412
+ );
413
+ }
414
+ rejectUnknownKeys(
415
+ value,
416
+ ['playerId', 'promptIdentity'],
417
+ `playbook session roleBindings.${roleId}`,
418
+ );
419
+ defineEnumerableDataProperty(bindings, roleId, Object.freeze({
420
+ playerId: requireNonEmptyString(
421
+ value.playerId,
422
+ `playbook session roleBindings.${roleId}.playerId`,
423
+ ),
424
+ promptIdentity: requireNonEmptyString(
425
+ value.promptIdentity,
426
+ `playbook session roleBindings.${roleId}.promptIdentity`,
427
+ ),
428
+ }));
429
+ }
430
+ return Object.freeze(bindings);
431
+ }
432
+
388
433
  /** Validate session causality and detach its immutable identity from the host. */
389
434
  export function snapshotPlaybookSession(
390
435
  session: PlaybookSession,
@@ -452,6 +497,10 @@ export function snapshotPlaybookSession(
452
497
  sessionDescriptors,
453
498
  'playerSessions',
454
499
  );
500
+ const hasRoleBindings = Object.prototype.hasOwnProperty.call(
501
+ sessionDescriptors,
502
+ 'roleBindings',
503
+ );
455
504
  let parentSessionId: string | undefined;
456
505
  let parentCallId: string | undefined;
457
506
  if (depth === 0) {
@@ -500,6 +549,9 @@ export function snapshotPlaybookSession(
500
549
  const playerSessions = hasPlayerSessions
501
550
  ? capturedSessionStore(sessionDescriptors)
502
551
  : undefined;
552
+ const roleBindings = hasRoleBindings
553
+ ? capturedRoleBindings(sessionDescriptors)
554
+ : undefined;
503
555
  return Object.freeze({
504
556
  sessionId,
505
557
  playbookId,
@@ -507,6 +559,7 @@ export function snapshotPlaybookSession(
507
559
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
508
560
  ...(parentCallId === undefined ? {} : { parentCallId }),
509
561
  depth,
562
+ ...(roleBindings === undefined ? {} : { roleBindings }),
510
563
  ...(playerSessions === undefined ? {} : { playerSessions }),
511
564
  ports,
512
565
  });
@@ -757,23 +810,117 @@ const SNAPSHOT_SEQUENCE_KEYS = [
757
810
  'playbookCall',
758
811
  ] as const;
759
812
 
760
- // DR-014 §1: validate and detach a host-supplied runtime snapshot before
761
- // restore touches any state. Rejects a schema-version or playbook-id
762
- // mismatch with a path-named error.
813
+ export interface PlaybookRuntimeSnapshotValidationOptions {
814
+ /**
815
+ * Opt in only when the restore path will prepare and confirm the suspended
816
+ * call transaction. The default is fail-closed so a legacy restore cannot
817
+ * reopen or ignore it.
818
+ */
819
+ allowSuspendedCall?: boolean;
820
+ }
821
+
822
+ function snapshotSuspendedCall(
823
+ value: unknown,
824
+ path = 'runtime snapshot suspendedCall',
825
+ ): PlaybookSuspendedCall {
826
+ const captured = snapshotJsonValue(value, path);
827
+ if (!isRecord(captured)) {
828
+ throw new TypeError(`${path} must be an object`);
829
+ }
830
+ rejectUnknownKeys(
831
+ captured,
832
+ ['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'],
833
+ path,
834
+ );
835
+ const call: PlaybookSuspendedCall = {
836
+ callId: requireNonEmptyString(captured.callId, `${path}.callId`),
837
+ stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
838
+ playbookId: requireNonEmptyString(
839
+ captured.playbookId,
840
+ `${path}.playbookId`,
841
+ ),
842
+ text: requireNonEmptyString(captured.text, `${path}.text`),
843
+ childSessionId: requireNonEmptyString(
844
+ captured.childSessionId,
845
+ `${path}.childSessionId`,
846
+ ),
847
+ };
848
+ if (own(captured, 'turnId')) {
849
+ if (
850
+ !Number.isSafeInteger(captured.turnId) ||
851
+ (captured.turnId as number) <= 0
852
+ ) {
853
+ throw new TypeError(`${path}.turnId must be a positive integer`);
854
+ }
855
+ call.turnId = captured.turnId as number;
856
+ }
857
+ return Object.freeze(call);
858
+ }
859
+
860
+ // DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
861
+ // schema-3 runtime snapshot before restore touches any state. A suspended
862
+ // call is rejected unless the restore path explicitly promises to seed and
863
+ // claim it; older schemas are rejected rather than guessing role identity.
763
864
  export function assertPlaybookRuntimeSnapshot(
764
865
  value: unknown,
765
866
  expectedPlaybookId: string,
867
+ options: PlaybookRuntimeSnapshotValidationOptions = {},
766
868
  ): PlaybookRuntimeSnapshot {
767
- if (!isRecord(value)) {
869
+ const snapshot = snapshotJsonValue(value, 'runtime snapshot');
870
+ if (!isRecord(snapshot)) {
768
871
  throw new TypeError('runtime snapshot must be an object');
769
872
  }
770
- if (value.schemaVersion !== 1) {
873
+ const capturedOptions = snapshotJsonValue(
874
+ options,
875
+ 'runtime snapshot validation options',
876
+ );
877
+ if (!isRecord(capturedOptions)) {
878
+ throw new TypeError('runtime snapshot validation options must be an object');
879
+ }
880
+ rejectUnknownKeys(
881
+ capturedOptions,
882
+ ['allowSuspendedCall'],
883
+ 'runtime snapshot validation options',
884
+ );
885
+ if (
886
+ capturedOptions.allowSuspendedCall !== undefined &&
887
+ typeof capturedOptions.allowSuspendedCall !== 'boolean'
888
+ ) {
771
889
  throw new TypeError(
772
- `runtime snapshot schemaVersion ${String(value.schemaVersion)} is not supported (expected 1)`,
890
+ 'runtime snapshot validation options.allowSuspendedCall must be boolean',
773
891
  );
774
892
  }
893
+ const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
894
+ if (snapshot.schemaVersion !== 3) {
895
+ throw new TypeError(
896
+ `runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
897
+ );
898
+ }
899
+ rejectUnknownKeys(
900
+ snapshot,
901
+ [
902
+ 'schemaVersion',
903
+ 'playbookId',
904
+ 'machine',
905
+ 'roleResumeTokens',
906
+ 'sequences',
907
+ 'state',
908
+ 'pendingBossQuestions',
909
+ 'suspendedCall',
910
+ ],
911
+ 'runtime snapshot',
912
+ );
913
+ let suspendedCall: PlaybookSuspendedCall | undefined;
914
+ if (own(snapshot, 'suspendedCall')) {
915
+ suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
916
+ if (!allowSuspendedCall) {
917
+ throw new TypeError(
918
+ 'runtime snapshot suspendedCall requires a restore path that explicitly allows it',
919
+ );
920
+ }
921
+ }
775
922
  const playbookId = requireNonEmptyString(
776
- value.playbookId,
923
+ snapshot.playbookId,
777
924
  'runtime snapshot playbookId',
778
925
  );
779
926
  if (playbookId !== expectedPlaybookId) {
@@ -781,32 +928,37 @@ export function assertPlaybookRuntimeSnapshot(
781
928
  `runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`,
782
929
  );
783
930
  }
784
- if (!isRecord(value.machine)) {
931
+ if (!isRecord(snapshot.machine)) {
785
932
  throw new TypeError('runtime snapshot machine must be an object');
786
933
  }
787
- const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
788
- if (!isRecord(value.playerResumeTokens)) {
934
+ const machine = snapshot.machine;
935
+ if (!isRecord(snapshot.roleResumeTokens)) {
789
936
  throw new TypeError(
790
- 'runtime snapshot playerResumeTokens must be an object',
937
+ 'runtime snapshot roleResumeTokens must be an object',
791
938
  );
792
939
  }
793
- const playerResumeTokens: Record<string, string> = {};
794
- for (const [playerId, token] of Object.entries(value.playerResumeTokens)) {
940
+ const roleResumeTokens: Record<string, string> = {};
941
+ for (const [roleId, token] of Object.entries(snapshot.roleResumeTokens)) {
795
942
  defineEnumerableDataProperty(
796
- playerResumeTokens,
797
- playerId,
943
+ roleResumeTokens,
944
+ requireNonEmptyString(roleId, 'runtime snapshot roleResumeTokens role id'),
798
945
  requireNonEmptyString(
799
946
  token,
800
- `runtime snapshot playerResumeTokens.${playerId}`,
947
+ `runtime snapshot roleResumeTokens.${roleId}`,
801
948
  ),
802
949
  );
803
950
  }
804
- if (!isRecord(value.sequences)) {
951
+ if (!isRecord(snapshot.sequences)) {
805
952
  throw new TypeError('runtime snapshot sequences must be an object');
806
953
  }
954
+ rejectUnknownKeys(
955
+ snapshot.sequences,
956
+ [...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'],
957
+ 'runtime snapshot sequences',
958
+ );
807
959
  const sequences = {} as PlaybookRuntimeSnapshot['sequences'];
808
960
  for (const key of SNAPSHOT_SEQUENCE_KEYS) {
809
- const sequence = value.sequences[key];
961
+ const sequence = snapshot.sequences[key];
810
962
  if (!Number.isSafeInteger(sequence) || (sequence as number) < 0) {
811
963
  throw new TypeError(
812
964
  `runtime snapshot sequences.${key} must be a non-negative integer`,
@@ -814,7 +966,7 @@ export function assertPlaybookRuntimeSnapshot(
814
966
  }
815
967
  sequences[key] = sequence as number;
816
968
  }
817
- const captainCall = value.sequences.captainCall;
969
+ const captainCall = snapshot.sequences.captainCall;
818
970
  if (captainCall !== undefined) {
819
971
  if (!Number.isSafeInteger(captainCall) || (captainCall as number) < 0) {
820
972
  throw new TypeError(
@@ -823,26 +975,84 @@ export function assertPlaybookRuntimeSnapshot(
823
975
  }
824
976
  sequences.captainCall = captainCall as number;
825
977
  }
826
- validateState(value.state, 'runtime snapshot state');
827
- const state = snapshotJsonValue(
828
- value.state,
829
- 'runtime snapshot state',
830
- ) as unknown as PlaybookState;
831
- if (!Array.isArray(value.pendingBossQuestions)) {
978
+ validateState(snapshot.state, 'runtime snapshot state');
979
+ const state = snapshot.state as unknown as PlaybookState;
980
+ if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
981
+ throw new TypeError(
982
+ `runtime snapshot state tagged ${SUSPENDED_TAG} requires suspendedCall`,
983
+ );
984
+ }
985
+ if (suspendedCall) {
986
+ if (sequences.playbookCall === 0) {
987
+ throw new TypeError(
988
+ 'runtime snapshot suspendedCall requires sequences.playbookCall greater than zero',
989
+ );
990
+ }
991
+ if (
992
+ suspendedCall.turnId !== undefined &&
993
+ suspendedCall.turnId > sequences.turn
994
+ ) {
995
+ throw new TypeError(
996
+ 'runtime snapshot suspendedCall.turnId must not exceed sequences.turn',
997
+ );
998
+ }
999
+ if (state.status !== 'active' || !state.quiescent) {
1000
+ throw new TypeError(
1001
+ 'runtime snapshot suspendedCall requires an active quiescent state',
1002
+ );
1003
+ }
1004
+ if (!state.tags.includes(SUSPENDED_TAG)) {
1005
+ throw new TypeError(
1006
+ `runtime snapshot suspendedCall requires state tag ${SUSPENDED_TAG}`,
1007
+ );
1008
+ }
1009
+ if (!state.activeStateIds.includes(suspendedCall.stateId)) {
1010
+ throw new TypeError(
1011
+ 'runtime snapshot suspendedCall.stateId must be active in snapshot state',
1012
+ );
1013
+ }
1014
+ }
1015
+ if (!Array.isArray(snapshot.pendingBossQuestions)) {
832
1016
  throw new TypeError(
833
1017
  'runtime snapshot pendingBossQuestions must be an array',
834
1018
  );
835
1019
  }
836
- const pendingBossQuestions = value.pendingBossQuestions.map(
1020
+ const pendingBossQuestions = snapshot.pendingBossQuestions.map(
837
1021
  (entry, index) => {
838
1022
  const path = `runtime snapshot pendingBossQuestions[${index}]`;
839
1023
  if (!isRecord(entry)) throw new TypeError(`${path} must be an object`);
1024
+ rejectUnknownKeys(
1025
+ entry,
1026
+ ['questionId', 'asker', 'question', 'sourceItem'],
1027
+ path,
1028
+ );
1029
+ if (!isRecord(entry.asker)) {
1030
+ throw new TypeError(`${path}.asker must be an object`);
1031
+ }
1032
+ let asker: PlaybookPendingBossQuestion['asker'];
1033
+ if (entry.asker.kind === 'captain') {
1034
+ rejectUnknownKeys(entry.asker, ['kind'], `${path}.asker`);
1035
+ asker = Object.freeze({ kind: 'captain' });
1036
+ } else if (entry.asker.kind === 'role') {
1037
+ rejectUnknownKeys(entry.asker, ['kind', 'roleId'], `${path}.asker`);
1038
+ asker = Object.freeze({
1039
+ kind: 'role',
1040
+ roleId: requireNonEmptyString(
1041
+ entry.asker.roleId,
1042
+ `${path}.asker.roleId`,
1043
+ ),
1044
+ });
1045
+ } else {
1046
+ throw new TypeError(
1047
+ `${path}.asker.kind must be "captain" or "role"`,
1048
+ );
1049
+ }
840
1050
  const question: PlaybookPendingBossQuestion = {
841
1051
  questionId: requireNonEmptyString(
842
1052
  entry.questionId,
843
1053
  `${path}.questionId`,
844
1054
  ),
845
- player: requireNonEmptyString(entry.player, `${path}.player`),
1055
+ asker,
846
1056
  question: requireNonEmptyString(entry.question, `${path}.question`),
847
1057
  ...(entry.sourceItem === undefined
848
1058
  ? {}
@@ -856,14 +1066,18 @@ export function assertPlaybookRuntimeSnapshot(
856
1066
  return Object.freeze(question);
857
1067
  },
858
1068
  );
859
- return Object.freeze({
860
- schemaVersion: 1,
1069
+ const fields = {
861
1070
  playbookId,
862
1071
  machine,
863
- playerResumeTokens: Object.freeze(playerResumeTokens),
1072
+ roleResumeTokens: Object.freeze(roleResumeTokens),
864
1073
  sequences: Object.freeze(sequences),
865
1074
  state,
866
1075
  pendingBossQuestions: Object.freeze(pendingBossQuestions),
1076
+ };
1077
+ return Object.freeze({
1078
+ schemaVersion: 3,
1079
+ ...fields,
1080
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
867
1081
  });
868
1082
  }
869
1083
 
@@ -916,15 +1130,24 @@ export class NestedPlaybookCallError extends Error {
916
1130
  interface ActiveCall {
917
1131
  readonly callId: string;
918
1132
  readonly input: NestedPlaybookInput;
1133
+ readonly turnId?: number;
919
1134
  readonly deferred: Deferred<JsonValue | undefined>;
920
1135
  readonly finished: Deferred<void>;
921
1136
  readonly controller: AbortController;
922
1137
  readonly signal: AbortSignal;
923
- phase: 'starting' | 'suspended' | 'settling';
1138
+ phase: 'starting' | 'restoring' | 'suspended' | 'settling';
924
1139
  childSessionId?: string;
925
1140
  abortListener?: () => void;
926
1141
  settlement?: Promise<void>;
927
1142
  runError?: unknown;
1143
+ restoreRolledBack?: boolean;
1144
+ }
1145
+
1146
+ interface NestedPlaybookRestoreMode {
1147
+ readonly call?: PlaybookSuspendedCall;
1148
+ state: 'armed' | 'claimed' | 'failed';
1149
+ active?: ActiveCall;
1150
+ error?: unknown;
928
1151
  }
929
1152
 
930
1153
  export interface PendingCallObserver {
@@ -938,6 +1161,15 @@ export interface NestedPlaybookBridge<
938
1161
  TInput extends NestedPlaybookInput = NestedPlaybookInput,
939
1162
  > extends PendingCallObserver {
940
1163
  actorLogic: PromiseActorLogic<JsonValue | undefined, TInput>;
1164
+ /** Arm fail-closed actor startup for a snapshot with zero or one nested call. */
1165
+ prepareRestore(call?: PlaybookSuspendedCall): void;
1166
+ /**
1167
+ * Commit restore startup after the persisted machine recreated exactly the
1168
+ * expected zero or one nested invocation.
1169
+ */
1170
+ confirmRestore(): void;
1171
+ /** Complete durable identity; undefined until a normal or restored call suspends. */
1172
+ getSuspendedCall(): PlaybookSuspendedCall | undefined;
941
1173
  resume(input: {
942
1174
  callId: string;
943
1175
  result: PlaybookCallResult;
@@ -1061,6 +1293,9 @@ export function validatePlayerResult(
1061
1293
  );
1062
1294
  validateRunStatus(result.status, `${path}.status`);
1063
1295
  validateOptionalString(result, 'resumeToken', path);
1296
+ if (result.resumeToken !== undefined) {
1297
+ requireNonEmptyString(result.resumeToken, `${path}.resumeToken`);
1298
+ }
1064
1299
  validateOptionalString(result, 'finalText', path);
1065
1300
  validateOptionalString(result, 'error', path);
1066
1301
  return result as unknown as PlayerResult;
@@ -1267,6 +1502,7 @@ export function createNestedPlaybookBridge<
1267
1502
  TInput extends NestedPlaybookInput = NestedPlaybookInput,
1268
1503
  >(options: NestedPlaybookBridgeOptions): NestedPlaybookBridge<TInput> {
1269
1504
  let current: ActiveCall | undefined;
1505
+ let restoreMode: NestedPlaybookRestoreMode | undefined;
1270
1506
  let disposed = false;
1271
1507
  const usedCallIds = new Set<string>();
1272
1508
  const pendingListeners = new Set<
@@ -1307,10 +1543,38 @@ export function createNestedPlaybookBridge<
1307
1543
  }
1308
1544
  : undefined;
1309
1545
 
1310
- const clear = (active: ActiveCall): void => {
1546
+ const suspendedIdentity = (
1547
+ active: ActiveCall | undefined,
1548
+ ): PlaybookSuspendedCall | undefined =>
1549
+ active?.phase === 'suspended' && active.childSessionId
1550
+ ? Object.freeze({
1551
+ callId: active.callId,
1552
+ stateId: active.input.stateId,
1553
+ playbookId: active.input.playbookId,
1554
+ text: active.input.text,
1555
+ childSessionId: active.childSessionId,
1556
+ ...(active.turnId === undefined ? {} : { turnId: active.turnId }),
1557
+ })
1558
+ : undefined;
1559
+
1560
+ const failRestoreMode = (
1561
+ mode: NestedPlaybookRestoreMode,
1562
+ error: unknown,
1563
+ ): void => {
1564
+ mode.state = 'failed';
1565
+ mode.error = error;
1566
+ reportControlPlaneError(error);
1567
+ };
1568
+
1569
+ const detachAbortListener = (active: ActiveCall): void => {
1311
1570
  if (active.abortListener) {
1312
1571
  active.signal.removeEventListener('abort', active.abortListener);
1572
+ active.abortListener = undefined;
1313
1573
  }
1574
+ };
1575
+
1576
+ const clear = (active: ActiveCall): void => {
1577
+ detachAbortListener(active);
1314
1578
  if (current === active) current = undefined;
1315
1579
  };
1316
1580
 
@@ -1443,35 +1707,202 @@ export function createNestedPlaybookBridge<
1443
1707
  }
1444
1708
  };
1445
1709
 
1710
+ const rollbackRestoredCall = (
1711
+ mode: NestedPlaybookRestoreMode,
1712
+ error: unknown,
1713
+ ): ActiveCall | undefined => {
1714
+ const active = mode.active;
1715
+ mode.state = 'failed';
1716
+ mode.error = error;
1717
+ mode.active = undefined;
1718
+ if (!active) return undefined;
1719
+ active.phase = 'settling';
1720
+ active.restoreRolledBack = true;
1721
+ clear(active);
1722
+ usedCallIds.delete(active.callId);
1723
+ active.deferred.reject(error);
1724
+ return active;
1725
+ };
1726
+
1727
+ const publishSuspendedCall = (active: ActiveCall): void => {
1728
+ if (active.phase !== 'suspended') {
1729
+ throw new Error(`playbook call ${active.callId} is not suspended`);
1730
+ }
1731
+ const abortListener = (): void => {
1732
+ if (active.phase !== 'suspended') return;
1733
+ const result = resultFromThrown(
1734
+ active.input.playbookId,
1735
+ active.childSessionId,
1736
+ active.signal.reason ?? new Error('Nested playbook invocation aborted'),
1737
+ true,
1738
+ );
1739
+ void settlePending(active, result).catch((error: unknown) => {
1740
+ reportBackgroundError(error);
1741
+ });
1742
+ };
1743
+ active.abortListener = abortListener;
1744
+ active.signal.addEventListener('abort', abortListener, { once: true });
1745
+ const pendingCall = pendingIdentity(active);
1746
+ if (!pendingCall) {
1747
+ throw new Error('suspended call identity was not recorded');
1748
+ }
1749
+ for (const listener of pendingListeners) {
1750
+ try {
1751
+ listener(pendingCall);
1752
+ } catch (error) {
1753
+ reportBackgroundError(error);
1754
+ }
1755
+ }
1756
+ if (active.signal.aborted) abortListener();
1757
+ };
1758
+
1759
+ const waitOnSuspendedCall = async (
1760
+ active: ActiveCall,
1761
+ ): Promise<JsonValue | undefined> => {
1762
+ publishSuspendedCall(active);
1763
+ return await active.deferred.promise;
1764
+ };
1765
+
1446
1766
  const actorLogic = fromPromise<JsonValue | undefined, TInput>(
1447
1767
  async ({ input, signal: invocationSignal }) => {
1448
1768
  if (disposed) {
1449
1769
  rejectControlPlane(new Error('nested playbook bridge is disposed'));
1450
1770
  }
1771
+ const normalizedInput = (() => {
1772
+ try {
1773
+ return {
1774
+ stateId: requireNonEmptyString(
1775
+ input.stateId,
1776
+ 'playbook input stateId',
1777
+ ),
1778
+ playbookId: requireNonEmptyString(
1779
+ input.playbookId,
1780
+ 'playbook input playbookId',
1781
+ ),
1782
+ text: requireNonEmptyString(input.text, 'playbook input text'),
1783
+ };
1784
+ } catch (error) {
1785
+ const mode = restoreMode;
1786
+ if (mode) {
1787
+ if (mode.state === 'claimed') {
1788
+ rollbackRestoredCall(mode, error);
1789
+ reportControlPlaneError(error);
1790
+ } else failRestoreMode(mode, error);
1791
+ throw error;
1792
+ }
1793
+ return rejectControlPlane(error);
1794
+ }
1795
+ })();
1796
+
1797
+ const mode = restoreMode;
1798
+ if (mode) {
1799
+ if (mode.state !== 'armed') {
1800
+ const callId = mode.call?.callId ?? 'without a descriptor';
1801
+ const error = new Error(
1802
+ mode.state === 'claimed'
1803
+ ? `restored playbook call ${callId} was claimed more than once`
1804
+ : `restored playbook call ${callId} is no longer claimable`,
1805
+ );
1806
+ if (mode.state === 'claimed') rollbackRestoredCall(mode, error);
1807
+ else mode.error ??= error;
1808
+ reportControlPlaneError(error);
1809
+ throw error;
1810
+ }
1811
+ const seed = mode.call;
1812
+ if (!seed) {
1813
+ const error = new Error(
1814
+ 'restored machine invoked a nested playbook without a suspendedCall descriptor',
1815
+ );
1816
+ failRestoreMode(mode, error);
1817
+ throw error;
1818
+ }
1819
+ for (const field of ['stateId', 'playbookId', 'text'] as const) {
1820
+ if (normalizedInput[field] !== seed[field]) {
1821
+ const error = new Error(
1822
+ `restored playbook call ${seed.callId} ${field} does not match its persisted input`,
1823
+ );
1824
+ failRestoreMode(mode, error);
1825
+ throw error;
1826
+ }
1827
+ }
1828
+ if (usedCallIds.has(seed.callId)) {
1829
+ const error = new Error(
1830
+ `restored duplicate playbook call id ${seed.callId}`,
1831
+ );
1832
+ failRestoreMode(mode, error);
1833
+ throw error;
1834
+ }
1835
+ const controller = new AbortController();
1836
+ let callSignal: AbortSignal;
1837
+ try {
1838
+ callSignal = combineAbortSignals(
1839
+ invocationSignal,
1840
+ options.getBoundarySignal?.(),
1841
+ controller.signal,
1842
+ );
1843
+ } catch (error) {
1844
+ failRestoreMode(mode, error);
1845
+ throw error;
1846
+ }
1847
+ const active: ActiveCall = {
1848
+ callId: seed.callId,
1849
+ input: normalizedInput,
1850
+ ...(seed.turnId === undefined
1851
+ ? {}
1852
+ : { turnId: seed.turnId }),
1853
+ deferred: deferred<JsonValue | undefined>(),
1854
+ finished: deferred<void>(),
1855
+ controller,
1856
+ signal: callSignal,
1857
+ phase: 'restoring',
1858
+ childSessionId: seed.childSessionId,
1859
+ };
1860
+ usedCallIds.add(active.callId);
1861
+ current = active;
1862
+ mode.state = 'claimed';
1863
+ mode.active = active;
1864
+ const restoreAbortListener = (): void => {
1865
+ if (
1866
+ restoreMode !== mode ||
1867
+ mode.state !== 'claimed' ||
1868
+ mode.active !== active ||
1869
+ active.phase !== 'restoring'
1870
+ ) {
1871
+ return;
1872
+ }
1873
+ rollbackRestoredCall(
1874
+ mode,
1875
+ active.signal.reason ??
1876
+ new Error('Restored nested playbook invocation aborted'),
1877
+ );
1878
+ };
1879
+ active.abortListener = restoreAbortListener;
1880
+ active.signal.addEventListener('abort', restoreAbortListener, {
1881
+ once: true,
1882
+ });
1883
+ if (active.signal.aborted) restoreAbortListener();
1884
+ try {
1885
+ return await active.deferred.promise;
1886
+ } catch (error) {
1887
+ if (!active.restoreRolledBack) active.runError = error;
1888
+ throw error;
1889
+ } finally {
1890
+ active.finished.resolve(undefined);
1891
+ }
1892
+ }
1893
+
1451
1894
  if (current) {
1452
1895
  rejectControlPlane(
1453
1896
  new Error(`playbook call ${current.callId} is already outstanding`),
1454
1897
  );
1455
1898
  }
1456
- const [normalizedInput, callId] = (() => {
1899
+
1900
+ const callId = (() => {
1457
1901
  try {
1458
- return [
1459
- {
1460
- stateId: requireNonEmptyString(
1461
- input.stateId,
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;
1902
+ return requireNonEmptyString(
1903
+ options.nextCallId(),
1904
+ 'allocated playbook call id',
1905
+ );
1475
1906
  } catch (error) {
1476
1907
  return rejectControlPlane(error);
1477
1908
  }
@@ -1665,34 +2096,7 @@ export function createNestedPlaybookBridge<
1665
2096
 
1666
2097
  active.phase = 'suspended';
1667
2098
  active.childSessionId = start.childSessionId;
1668
- const abortListener = (): void => {
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;
2099
+ return await waitOnSuspendedCall(active);
1696
2100
  } catch (error) {
1697
2101
  active.runError = error;
1698
2102
  throw error;
@@ -1705,6 +2109,16 @@ export function createNestedPlaybookBridge<
1705
2109
  const abortPending = async (
1706
2110
  error: unknown = new Error('Nested playbook call aborted'),
1707
2111
  ): Promise<void> => {
2112
+ const mode = restoreMode;
2113
+ if (mode) {
2114
+ restoreMode = undefined;
2115
+ const restored =
2116
+ mode.state === 'claimed'
2117
+ ? rollbackRestoredCall(mode, error)
2118
+ : undefined;
2119
+ if (restored) await restored.finished.promise;
2120
+ return;
2121
+ }
1708
2122
  const active = current;
1709
2123
  if (!active) return;
1710
2124
  if (!active.controller.signal.aborted) active.controller.abort(error);
@@ -1740,6 +2154,79 @@ export function createNestedPlaybookBridge<
1740
2154
  return {
1741
2155
  actorLogic,
1742
2156
  getPendingCall: () => pendingIdentity(current),
2157
+ getSuspendedCall: () => suspendedIdentity(current),
2158
+ prepareRestore(call) {
2159
+ // Capture the complete host-owned descriptor before observing or
2160
+ // mutating bridge state, so a rejected preparation cannot leave state.
2161
+ const captured =
2162
+ call === undefined
2163
+ ? undefined
2164
+ : snapshotSuspendedCall(call, 'restored playbook call');
2165
+ if (disposed) {
2166
+ rejectControlPlane(new Error('nested playbook bridge is disposed'));
2167
+ }
2168
+ if (current) {
2169
+ rejectControlPlane(
2170
+ new Error(`playbook call ${current.callId} is already outstanding`),
2171
+ );
2172
+ }
2173
+ if (restoreMode) {
2174
+ rejectControlPlane(
2175
+ new Error('nested playbook bridge restore is already prepared'),
2176
+ );
2177
+ }
2178
+ if (captured && usedCallIds.has(captured.callId)) {
2179
+ rejectControlPlane(
2180
+ new Error(`restored duplicate playbook call id ${captured.callId}`),
2181
+ );
2182
+ }
2183
+ restoreMode = {
2184
+ ...(captured === undefined ? {} : { call: captured }),
2185
+ state: 'armed',
2186
+ };
2187
+ },
2188
+ confirmRestore() {
2189
+ const mode = restoreMode;
2190
+ if (!mode) {
2191
+ throw new Error('nested playbook bridge restore is not prepared');
2192
+ }
2193
+ if (mode.state === 'failed') {
2194
+ restoreMode = undefined;
2195
+ throw mode.error;
2196
+ }
2197
+ if (mode.call === undefined) {
2198
+ restoreMode = undefined;
2199
+ return;
2200
+ }
2201
+ if (mode.state !== 'claimed' || !mode.active) {
2202
+ const error = new Error(
2203
+ `restored playbook call ${mode.call.callId} was not claimed by actor startup`,
2204
+ );
2205
+ restoreMode = undefined;
2206
+ reportControlPlaneError(error);
2207
+ throw error;
2208
+ }
2209
+ const active = mode.active;
2210
+ if (active.signal.aborted) {
2211
+ const error =
2212
+ active.signal.reason ??
2213
+ new Error('Restored nested playbook invocation aborted');
2214
+ rollbackRestoredCall(mode, error);
2215
+ restoreMode = undefined;
2216
+ throw error;
2217
+ }
2218
+ try {
2219
+ detachAbortListener(active);
2220
+ active.phase = 'suspended';
2221
+ restoreMode = undefined;
2222
+ publishSuspendedCall(active);
2223
+ } catch (error) {
2224
+ rollbackRestoredCall(mode, error);
2225
+ restoreMode = undefined;
2226
+ reportControlPlaneError(error);
2227
+ throw error;
2228
+ }
2229
+ },
1743
2230
  subscribePendingCall(listener) {
1744
2231
  if (disposed) return () => undefined;
1745
2232
  pendingListeners.add(listener);
@@ -1807,6 +2294,7 @@ export function createNestedPlaybookBridge<
1807
2294
  throw active.runError;
1808
2295
  }
1809
2296
  } finally {
2297
+ restoreMode = undefined;
1810
2298
  pendingListeners.clear();
1811
2299
  }
1812
2300
  },