@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.
Files changed (72) hide show
  1. package/README.md +22 -14
  2. package/docs/cli.md +138 -90
  3. package/docs/configuration.md +113 -29
  4. package/docs/embedding.md +24 -16
  5. package/package.json +42 -21
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
  8. package/reference/sdlc/code.md +55 -97
  9. package/reference/sdlc/code.playbook/bin/launch-config.js +938 -0
  10. package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
  11. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  12. package/reference/sdlc/code.playbook/bin/run.js +1171 -983
  13. package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  18. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  19. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  20. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  22. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  23. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  25. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  26. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
  28. package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
  31. package/reference/sdlc/decide.md +54 -0
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  35. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  37. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +545 -372
  38. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  40. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  42. package/reference/sdlc/review.md +81 -0
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  44. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  45. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  46. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  48. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  49. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  51. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  52. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  53. package/slc/gears2fsm.md +13 -4
  54. package/slc/link.md +83 -14
  55. package/slc/text2gears.md +22 -2
  56. package/src/runtime.d.ts +21 -2
  57. package/src/runtime.ts +38 -6
  58. package/src/xstate-playbook-runtime.d.ts +9 -2
  59. package/src/xstate-playbook-runtime.js +319 -35
  60. package/src/xstate-playbook-runtime.ts +412 -41
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +384 -57
  63. package/src/xstate-runtime.ts +542 -71
  64. package/reference/sdlc/discuss.md +0 -93
  65. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  66. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  67. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  68. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  69. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  70. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  71. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  72. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -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
- // DR-014 §1: validate and detach a host-supplied runtime snapshot before
710
- // restore touches any state. Rejects a schema-version or playbook-id
711
- // mismatch with a path-named error.
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
- if (!isRecord(value)) {
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
- if (value.schemaVersion !== 1) {
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(value.schemaVersion)} is not supported (expected 1)`,
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
- value.playbookId,
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(value.machine)) {
884
+ if (!isRecord(snapshot.machine)) {
734
885
  throw new TypeError('runtime snapshot machine must be an object');
735
886
  }
736
- const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
737
- if (!isRecord(value.playerResumeTokens)) {
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(value.playerResumeTokens)) {
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(value.sequences)) {
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 = value.sequences[key];
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 = value.sequences.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(value.state, 'runtime snapshot state');
776
- const state = snapshotJsonValue(
777
- value.state,
778
- 'runtime snapshot state',
779
- ) as unknown as PlaybookState;
780
- if (!Array.isArray(value.pendingBossQuestions)) {
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 = value.pendingBossQuestions.map(
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
- return Object.freeze({
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 clear = (active: ActiveCall): void => {
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
- const [normalizedInput, callId] = (() => {
1831
+
1832
+ const callId = (() => {
1406
1833
  try {
1407
- return [
1408
- {
1409
- stateId: requireNonEmptyString(
1410
- input.stateId,
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
- const abortListener = (): void => {
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
  },