@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
@@ -1,5 +1,5 @@
1
1
  import { type AnyActorRef, type PromiseActorLogic, type SnapshotFrom } from 'xstate';
2
- import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlayerResult } from './runtime.js';
2
+ import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookSuspendedCall, PlayerResult } from './runtime.js';
3
3
  export * from './xstate-playbook-runtime.js';
4
4
  /**
5
5
  * Compose invocation-lifetime and imperative-boundary cancellation without
@@ -31,7 +31,15 @@ export interface SnapshotNormalizationOptions {
31
31
  }
32
32
  export declare function normalizePlaybookSnapshot(snapshot: unknown, options?: SnapshotNormalizationOptions): PlaybookState;
33
33
  export declare function detachPersistedMachineSnapshot(persisted: unknown): JsonValue;
34
- export declare function assertPlaybookRuntimeSnapshot(value: unknown, expectedPlaybookId: string): PlaybookRuntimeSnapshot;
34
+ export interface PlaybookRuntimeSnapshotValidationOptions {
35
+ /**
36
+ * Opt in only when the restore path will prepare and confirm the suspended
37
+ * call transaction. The default is fail-closed so a legacy restore cannot
38
+ * reopen or ignore it.
39
+ */
40
+ allowSuspendedCall?: boolean;
41
+ }
42
+ export declare function assertPlaybookRuntimeSnapshot(value: unknown, expectedPlaybookId: string, options?: PlaybookRuntimeSnapshotValidationOptions): PlaybookRuntimeSnapshot;
35
43
  export interface NestedPlaybookInput {
36
44
  stateId: string;
37
45
  playbookId: string;
@@ -68,6 +76,15 @@ export interface PendingCallObserver {
68
76
  }
69
77
  export interface NestedPlaybookBridge<TInput extends NestedPlaybookInput = NestedPlaybookInput> extends PendingCallObserver {
70
78
  actorLogic: PromiseActorLogic<JsonValue | undefined, TInput>;
79
+ /** Arm fail-closed actor startup for a snapshot with zero or one nested call. */
80
+ prepareRestore(call?: PlaybookSuspendedCall): void;
81
+ /**
82
+ * Commit restore startup after the persisted machine recreated exactly the
83
+ * expected zero or one nested invocation.
84
+ */
85
+ confirmRestore(): void;
86
+ /** Complete durable identity; undefined until a normal or restored call suspends. */
87
+ getSuspendedCall(): PlaybookSuspendedCall | undefined;
71
88
  resume(input: {
72
89
  callId: string;
73
90
  result: PlaybookCallResult;
@@ -268,6 +268,25 @@ function capturedSessionStore(descriptors) {
268
268
  restore: method('restore'),
269
269
  });
270
270
  }
271
+ function capturedRoleBindings(descriptors) {
272
+ const captured = snapshotJsonValue(capturedDataValue(descriptors, 'roleBindings', 'playbook session roleBindings'), 'playbook session roleBindings');
273
+ if (!isRecord(captured)) {
274
+ throw new TypeError('playbook session roleBindings must be an object');
275
+ }
276
+ const bindings = {};
277
+ for (const [roleId, value] of Object.entries(captured)) {
278
+ requireNonEmptyString(roleId, 'playbook session roleBindings role id');
279
+ if (!isRecord(value)) {
280
+ throw new TypeError(`playbook session roleBindings.${roleId} must be an object`);
281
+ }
282
+ rejectUnknownKeys(value, ['playerId', 'promptIdentity'], `playbook session roleBindings.${roleId}`);
283
+ defineEnumerableDataProperty(bindings, roleId, Object.freeze({
284
+ playerId: requireNonEmptyString(value.playerId, `playbook session roleBindings.${roleId}.playerId`),
285
+ promptIdentity: requireNonEmptyString(value.promptIdentity, `playbook session roleBindings.${roleId}.promptIdentity`),
286
+ }));
287
+ }
288
+ return Object.freeze(bindings);
289
+ }
271
290
  /** Validate session causality and detach its immutable identity from the host. */
272
291
  export function snapshotPlaybookSession(session) {
273
292
  if (!isRecord(session)) {
@@ -287,6 +306,7 @@ export function snapshotPlaybookSession(session) {
287
306
  const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
288
307
  const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
289
308
  const hasPlayerSessions = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'playerSessions');
309
+ const hasRoleBindings = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'roleBindings');
290
310
  let parentSessionId;
291
311
  let parentCallId;
292
312
  if (depth === 0) {
@@ -320,6 +340,9 @@ export function snapshotPlaybookSession(session) {
320
340
  const playerSessions = hasPlayerSessions
321
341
  ? capturedSessionStore(sessionDescriptors)
322
342
  : undefined;
343
+ const roleBindings = hasRoleBindings
344
+ ? capturedRoleBindings(sessionDescriptors)
345
+ : undefined;
323
346
  return Object.freeze({
324
347
  sessionId,
325
348
  playbookId,
@@ -327,6 +350,7 @@ export function snapshotPlaybookSession(session) {
327
350
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
328
351
  ...(parentCallId === undefined ? {} : { parentCallId }),
329
352
  depth,
353
+ ...(roleBindings === undefined ? {} : { roleBindings }),
330
354
  ...(playerSessions === undefined ? {} : { playerSessions }),
331
355
  ports,
332
356
  });
@@ -526,61 +550,153 @@ const SNAPSHOT_SEQUENCE_KEYS = [
526
550
  'playerCall',
527
551
  'playbookCall',
528
552
  ];
529
- // DR-014 §1: validate and detach a host-supplied runtime snapshot before
530
- // restore touches any state. Rejects a schema-version or playbook-id
531
- // mismatch with a path-named error.
532
- export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
533
- if (!isRecord(value)) {
553
+ function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
554
+ const captured = snapshotJsonValue(value, path);
555
+ if (!isRecord(captured)) {
556
+ throw new TypeError(`${path} must be an object`);
557
+ }
558
+ rejectUnknownKeys(captured, ['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'], path);
559
+ const call = {
560
+ callId: requireNonEmptyString(captured.callId, `${path}.callId`),
561
+ stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
562
+ playbookId: requireNonEmptyString(captured.playbookId, `${path}.playbookId`),
563
+ text: requireNonEmptyString(captured.text, `${path}.text`),
564
+ childSessionId: requireNonEmptyString(captured.childSessionId, `${path}.childSessionId`),
565
+ };
566
+ if (own(captured, 'turnId')) {
567
+ if (!Number.isSafeInteger(captured.turnId) ||
568
+ captured.turnId <= 0) {
569
+ throw new TypeError(`${path}.turnId must be a positive integer`);
570
+ }
571
+ call.turnId = captured.turnId;
572
+ }
573
+ return Object.freeze(call);
574
+ }
575
+ // DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
576
+ // schema-3 runtime snapshot before restore touches any state. A suspended
577
+ // call is rejected unless the restore path explicitly promises to seed and
578
+ // claim it; older schemas are rejected rather than guessing role identity.
579
+ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options = {}) {
580
+ const snapshot = snapshotJsonValue(value, 'runtime snapshot');
581
+ if (!isRecord(snapshot)) {
534
582
  throw new TypeError('runtime snapshot must be an object');
535
583
  }
536
- if (value.schemaVersion !== 1) {
537
- throw new TypeError(`runtime snapshot schemaVersion ${String(value.schemaVersion)} is not supported (expected 1)`);
584
+ const capturedOptions = snapshotJsonValue(options, 'runtime snapshot validation options');
585
+ if (!isRecord(capturedOptions)) {
586
+ throw new TypeError('runtime snapshot validation options must be an object');
587
+ }
588
+ rejectUnknownKeys(capturedOptions, ['allowSuspendedCall'], 'runtime snapshot validation options');
589
+ if (capturedOptions.allowSuspendedCall !== undefined &&
590
+ typeof capturedOptions.allowSuspendedCall !== 'boolean') {
591
+ throw new TypeError('runtime snapshot validation options.allowSuspendedCall must be boolean');
592
+ }
593
+ const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
594
+ if (snapshot.schemaVersion !== 3) {
595
+ throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
596
+ }
597
+ rejectUnknownKeys(snapshot, [
598
+ 'schemaVersion',
599
+ 'playbookId',
600
+ 'machine',
601
+ 'roleResumeTokens',
602
+ 'sequences',
603
+ 'state',
604
+ 'pendingBossQuestions',
605
+ 'suspendedCall',
606
+ ], 'runtime snapshot');
607
+ let suspendedCall;
608
+ if (own(snapshot, 'suspendedCall')) {
609
+ suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
610
+ if (!allowSuspendedCall) {
611
+ throw new TypeError('runtime snapshot suspendedCall requires a restore path that explicitly allows it');
612
+ }
538
613
  }
539
- const playbookId = requireNonEmptyString(value.playbookId, 'runtime snapshot playbookId');
614
+ const playbookId = requireNonEmptyString(snapshot.playbookId, 'runtime snapshot playbookId');
540
615
  if (playbookId !== expectedPlaybookId) {
541
616
  throw new TypeError(`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`);
542
617
  }
543
- if (!isRecord(value.machine)) {
618
+ if (!isRecord(snapshot.machine)) {
544
619
  throw new TypeError('runtime snapshot machine must be an object');
545
620
  }
546
- const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
547
- if (!isRecord(value.playerResumeTokens)) {
548
- throw new TypeError('runtime snapshot playerResumeTokens must be an object');
621
+ const machine = snapshot.machine;
622
+ if (!isRecord(snapshot.roleResumeTokens)) {
623
+ throw new TypeError('runtime snapshot roleResumeTokens must be an object');
549
624
  }
550
- const playerResumeTokens = {};
551
- for (const [playerId, token] of Object.entries(value.playerResumeTokens)) {
552
- defineEnumerableDataProperty(playerResumeTokens, playerId, requireNonEmptyString(token, `runtime snapshot playerResumeTokens.${playerId}`));
625
+ const roleResumeTokens = {};
626
+ for (const [roleId, token] of Object.entries(snapshot.roleResumeTokens)) {
627
+ defineEnumerableDataProperty(roleResumeTokens, requireNonEmptyString(roleId, 'runtime snapshot roleResumeTokens role id'), requireNonEmptyString(token, `runtime snapshot roleResumeTokens.${roleId}`));
553
628
  }
554
- if (!isRecord(value.sequences)) {
629
+ if (!isRecord(snapshot.sequences)) {
555
630
  throw new TypeError('runtime snapshot sequences must be an object');
556
631
  }
632
+ rejectUnknownKeys(snapshot.sequences, [...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'], 'runtime snapshot sequences');
557
633
  const sequences = {};
558
634
  for (const key of SNAPSHOT_SEQUENCE_KEYS) {
559
- const sequence = value.sequences[key];
635
+ const sequence = snapshot.sequences[key];
560
636
  if (!Number.isSafeInteger(sequence) || sequence < 0) {
561
637
  throw new TypeError(`runtime snapshot sequences.${key} must be a non-negative integer`);
562
638
  }
563
639
  sequences[key] = sequence;
564
640
  }
565
- const captainCall = value.sequences.captainCall;
641
+ const captainCall = snapshot.sequences.captainCall;
566
642
  if (captainCall !== undefined) {
567
643
  if (!Number.isSafeInteger(captainCall) || captainCall < 0) {
568
644
  throw new TypeError('runtime snapshot sequences.captainCall must be a non-negative integer');
569
645
  }
570
646
  sequences.captainCall = captainCall;
571
647
  }
572
- validateState(value.state, 'runtime snapshot state');
573
- const state = snapshotJsonValue(value.state, 'runtime snapshot state');
574
- if (!Array.isArray(value.pendingBossQuestions)) {
648
+ validateState(snapshot.state, 'runtime snapshot state');
649
+ const state = snapshot.state;
650
+ if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
651
+ throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires suspendedCall`);
652
+ }
653
+ if (suspendedCall) {
654
+ if (sequences.playbookCall === 0) {
655
+ throw new TypeError('runtime snapshot suspendedCall requires sequences.playbookCall greater than zero');
656
+ }
657
+ if (suspendedCall.turnId !== undefined &&
658
+ suspendedCall.turnId > sequences.turn) {
659
+ throw new TypeError('runtime snapshot suspendedCall.turnId must not exceed sequences.turn');
660
+ }
661
+ if (state.status !== 'active' || !state.quiescent) {
662
+ throw new TypeError('runtime snapshot suspendedCall requires an active quiescent state');
663
+ }
664
+ if (!state.tags.includes(SUSPENDED_TAG)) {
665
+ throw new TypeError(`runtime snapshot suspendedCall requires state tag ${SUSPENDED_TAG}`);
666
+ }
667
+ if (!state.activeStateIds.includes(suspendedCall.stateId)) {
668
+ throw new TypeError('runtime snapshot suspendedCall.stateId must be active in snapshot state');
669
+ }
670
+ }
671
+ if (!Array.isArray(snapshot.pendingBossQuestions)) {
575
672
  throw new TypeError('runtime snapshot pendingBossQuestions must be an array');
576
673
  }
577
- const pendingBossQuestions = value.pendingBossQuestions.map((entry, index) => {
674
+ const pendingBossQuestions = snapshot.pendingBossQuestions.map((entry, index) => {
578
675
  const path = `runtime snapshot pendingBossQuestions[${index}]`;
579
676
  if (!isRecord(entry))
580
677
  throw new TypeError(`${path} must be an object`);
678
+ rejectUnknownKeys(entry, ['questionId', 'asker', 'question', 'sourceItem'], path);
679
+ if (!isRecord(entry.asker)) {
680
+ throw new TypeError(`${path}.asker must be an object`);
681
+ }
682
+ let asker;
683
+ if (entry.asker.kind === 'captain') {
684
+ rejectUnknownKeys(entry.asker, ['kind'], `${path}.asker`);
685
+ asker = Object.freeze({ kind: 'captain' });
686
+ }
687
+ else if (entry.asker.kind === 'role') {
688
+ rejectUnknownKeys(entry.asker, ['kind', 'roleId'], `${path}.asker`);
689
+ asker = Object.freeze({
690
+ kind: 'role',
691
+ roleId: requireNonEmptyString(entry.asker.roleId, `${path}.asker.roleId`),
692
+ });
693
+ }
694
+ else {
695
+ throw new TypeError(`${path}.asker.kind must be "captain" or "role"`);
696
+ }
581
697
  const question = {
582
698
  questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
583
- player: requireNonEmptyString(entry.player, `${path}.player`),
699
+ asker,
584
700
  question: requireNonEmptyString(entry.question, `${path}.question`),
585
701
  ...(entry.sourceItem === undefined
586
702
  ? {}
@@ -590,14 +706,18 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
590
706
  };
591
707
  return Object.freeze(question);
592
708
  });
593
- return Object.freeze({
594
- schemaVersion: 1,
709
+ const fields = {
595
710
  playbookId,
596
711
  machine,
597
- playerResumeTokens: Object.freeze(playerResumeTokens),
712
+ roleResumeTokens: Object.freeze(roleResumeTokens),
598
713
  sequences: Object.freeze(sequences),
599
714
  state,
600
715
  pendingBossQuestions: Object.freeze(pendingBossQuestions),
716
+ };
717
+ return Object.freeze({
718
+ schemaVersion: 3,
719
+ ...fields,
720
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
601
721
  });
602
722
  }
603
723
  export class NestedPlaybookCallError extends Error {
@@ -689,6 +809,9 @@ export function validatePlayerResult(value, path = 'player result') {
689
809
  rejectUnknownKeys(result, ['status', 'resumeToken', 'finalText', 'error'], path);
690
810
  validateRunStatus(result.status, `${path}.status`);
691
811
  validateOptionalString(result, 'resumeToken', path);
812
+ if (result.resumeToken !== undefined) {
813
+ requireNonEmptyString(result.resumeToken, `${path}.resumeToken`);
814
+ }
692
815
  validateOptionalString(result, 'finalText', path);
693
816
  validateOptionalString(result, 'error', path);
694
817
  return result;
@@ -821,6 +944,7 @@ function outputOrThrow(result) {
821
944
  }
822
945
  export function createNestedPlaybookBridge(options) {
823
946
  let current;
947
+ let restoreMode;
824
948
  let disposed = false;
825
949
  const usedCallIds = new Set();
826
950
  const pendingListeners = new Set();
@@ -853,10 +977,29 @@ export function createNestedPlaybookBridge(options) {
853
977
  childSessionId: active.childSessionId,
854
978
  }
855
979
  : undefined;
856
- const clear = (active) => {
980
+ const suspendedIdentity = (active) => active?.phase === 'suspended' && active.childSessionId
981
+ ? Object.freeze({
982
+ callId: active.callId,
983
+ stateId: active.input.stateId,
984
+ playbookId: active.input.playbookId,
985
+ text: active.input.text,
986
+ childSessionId: active.childSessionId,
987
+ ...(active.turnId === undefined ? {} : { turnId: active.turnId }),
988
+ })
989
+ : undefined;
990
+ const failRestoreMode = (mode, error) => {
991
+ mode.state = 'failed';
992
+ mode.error = error;
993
+ reportControlPlaneError(error);
994
+ };
995
+ const detachAbortListener = (active) => {
857
996
  if (active.abortListener) {
858
997
  active.signal.removeEventListener('abort', active.abortListener);
998
+ active.abortListener = undefined;
859
999
  }
1000
+ };
1001
+ const clear = (active) => {
1002
+ detachAbortListener(active);
860
1003
  if (current === active)
861
1004
  current = undefined;
862
1005
  };
@@ -972,23 +1115,171 @@ export function createNestedPlaybookBridge(options) {
972
1115
  throw error;
973
1116
  }
974
1117
  };
1118
+ const rollbackRestoredCall = (mode, error) => {
1119
+ const active = mode.active;
1120
+ mode.state = 'failed';
1121
+ mode.error = error;
1122
+ mode.active = undefined;
1123
+ if (!active)
1124
+ return undefined;
1125
+ active.phase = 'settling';
1126
+ active.restoreRolledBack = true;
1127
+ clear(active);
1128
+ usedCallIds.delete(active.callId);
1129
+ active.deferred.reject(error);
1130
+ return active;
1131
+ };
1132
+ const publishSuspendedCall = (active) => {
1133
+ if (active.phase !== 'suspended') {
1134
+ throw new Error(`playbook call ${active.callId} is not suspended`);
1135
+ }
1136
+ const abortListener = () => {
1137
+ if (active.phase !== 'suspended')
1138
+ return;
1139
+ const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ?? new Error('Nested playbook invocation aborted'), true);
1140
+ void settlePending(active, result).catch((error) => {
1141
+ reportBackgroundError(error);
1142
+ });
1143
+ };
1144
+ active.abortListener = abortListener;
1145
+ active.signal.addEventListener('abort', abortListener, { once: true });
1146
+ const pendingCall = pendingIdentity(active);
1147
+ if (!pendingCall) {
1148
+ throw new Error('suspended call identity was not recorded');
1149
+ }
1150
+ for (const listener of pendingListeners) {
1151
+ try {
1152
+ listener(pendingCall);
1153
+ }
1154
+ catch (error) {
1155
+ reportBackgroundError(error);
1156
+ }
1157
+ }
1158
+ if (active.signal.aborted)
1159
+ abortListener();
1160
+ };
1161
+ const waitOnSuspendedCall = async (active) => {
1162
+ publishSuspendedCall(active);
1163
+ return await active.deferred.promise;
1164
+ };
975
1165
  const actorLogic = fromPromise(async ({ input, signal: invocationSignal }) => {
976
1166
  if (disposed) {
977
1167
  rejectControlPlane(new Error('nested playbook bridge is disposed'));
978
1168
  }
1169
+ const normalizedInput = (() => {
1170
+ try {
1171
+ return {
1172
+ stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
1173
+ playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
1174
+ text: requireNonEmptyString(input.text, 'playbook input text'),
1175
+ };
1176
+ }
1177
+ catch (error) {
1178
+ const mode = restoreMode;
1179
+ if (mode) {
1180
+ if (mode.state === 'claimed') {
1181
+ rollbackRestoredCall(mode, error);
1182
+ reportControlPlaneError(error);
1183
+ }
1184
+ else
1185
+ failRestoreMode(mode, error);
1186
+ throw error;
1187
+ }
1188
+ return rejectControlPlane(error);
1189
+ }
1190
+ })();
1191
+ const mode = restoreMode;
1192
+ if (mode) {
1193
+ if (mode.state !== 'armed') {
1194
+ const callId = mode.call?.callId ?? 'without a descriptor';
1195
+ const error = new Error(mode.state === 'claimed'
1196
+ ? `restored playbook call ${callId} was claimed more than once`
1197
+ : `restored playbook call ${callId} is no longer claimable`);
1198
+ if (mode.state === 'claimed')
1199
+ rollbackRestoredCall(mode, error);
1200
+ else
1201
+ mode.error ??= error;
1202
+ reportControlPlaneError(error);
1203
+ throw error;
1204
+ }
1205
+ const seed = mode.call;
1206
+ if (!seed) {
1207
+ const error = new Error('restored machine invoked a nested playbook without a suspendedCall descriptor');
1208
+ failRestoreMode(mode, error);
1209
+ throw error;
1210
+ }
1211
+ for (const field of ['stateId', 'playbookId', 'text']) {
1212
+ if (normalizedInput[field] !== seed[field]) {
1213
+ const error = new Error(`restored playbook call ${seed.callId} ${field} does not match its persisted input`);
1214
+ failRestoreMode(mode, error);
1215
+ throw error;
1216
+ }
1217
+ }
1218
+ if (usedCallIds.has(seed.callId)) {
1219
+ const error = new Error(`restored duplicate playbook call id ${seed.callId}`);
1220
+ failRestoreMode(mode, error);
1221
+ throw error;
1222
+ }
1223
+ const controller = new AbortController();
1224
+ let callSignal;
1225
+ try {
1226
+ callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
1227
+ }
1228
+ catch (error) {
1229
+ failRestoreMode(mode, error);
1230
+ throw error;
1231
+ }
1232
+ const active = {
1233
+ callId: seed.callId,
1234
+ input: normalizedInput,
1235
+ ...(seed.turnId === undefined
1236
+ ? {}
1237
+ : { turnId: seed.turnId }),
1238
+ deferred: deferred(),
1239
+ finished: deferred(),
1240
+ controller,
1241
+ signal: callSignal,
1242
+ phase: 'restoring',
1243
+ childSessionId: seed.childSessionId,
1244
+ };
1245
+ usedCallIds.add(active.callId);
1246
+ current = active;
1247
+ mode.state = 'claimed';
1248
+ mode.active = active;
1249
+ const restoreAbortListener = () => {
1250
+ if (restoreMode !== mode ||
1251
+ mode.state !== 'claimed' ||
1252
+ mode.active !== active ||
1253
+ active.phase !== 'restoring') {
1254
+ return;
1255
+ }
1256
+ rollbackRestoredCall(mode, active.signal.reason ??
1257
+ new Error('Restored nested playbook invocation aborted'));
1258
+ };
1259
+ active.abortListener = restoreAbortListener;
1260
+ active.signal.addEventListener('abort', restoreAbortListener, {
1261
+ once: true,
1262
+ });
1263
+ if (active.signal.aborted)
1264
+ restoreAbortListener();
1265
+ try {
1266
+ return await active.deferred.promise;
1267
+ }
1268
+ catch (error) {
1269
+ if (!active.restoreRolledBack)
1270
+ active.runError = error;
1271
+ throw error;
1272
+ }
1273
+ finally {
1274
+ active.finished.resolve(undefined);
1275
+ }
1276
+ }
979
1277
  if (current) {
980
1278
  rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
981
1279
  }
982
- const [normalizedInput, callId] = (() => {
1280
+ const callId = (() => {
983
1281
  try {
984
- return [
985
- {
986
- stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
987
- playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
988
- text: requireNonEmptyString(input.text, 'playbook input text'),
989
- },
990
- requireNonEmptyString(options.nextCallId(), 'allocated playbook call id'),
991
- ];
1282
+ return requireNonEmptyString(options.nextCallId(), 'allocated playbook call id');
992
1283
  }
993
1284
  catch (error) {
994
1285
  return rejectControlPlane(error);
@@ -1119,32 +1410,7 @@ export function createNestedPlaybookBridge(options) {
1119
1410
  }
1120
1411
  active.phase = 'suspended';
1121
1412
  active.childSessionId = start.childSessionId;
1122
- const abortListener = () => {
1123
- if (active.phase !== 'suspended')
1124
- return;
1125
- const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
1126
- new Error('Nested playbook invocation aborted'), true);
1127
- void settlePending(active, result).catch((error) => {
1128
- reportBackgroundError(error);
1129
- });
1130
- };
1131
- active.abortListener = abortListener;
1132
- active.signal.addEventListener('abort', abortListener, { once: true });
1133
- const pendingCall = pendingIdentity(active);
1134
- if (!pendingCall) {
1135
- throw new Error('suspended call identity was not recorded');
1136
- }
1137
- for (const listener of pendingListeners) {
1138
- try {
1139
- listener(pendingCall);
1140
- }
1141
- catch (error) {
1142
- reportBackgroundError(error);
1143
- }
1144
- }
1145
- if (active.signal.aborted)
1146
- abortListener();
1147
- return await active.deferred.promise;
1413
+ return await waitOnSuspendedCall(active);
1148
1414
  }
1149
1415
  catch (error) {
1150
1416
  active.runError = error;
@@ -1155,6 +1421,16 @@ export function createNestedPlaybookBridge(options) {
1155
1421
  }
1156
1422
  });
1157
1423
  const abortPending = async (error = new Error('Nested playbook call aborted')) => {
1424
+ const mode = restoreMode;
1425
+ if (mode) {
1426
+ restoreMode = undefined;
1427
+ const restored = mode.state === 'claimed'
1428
+ ? rollbackRestoredCall(mode, error)
1429
+ : undefined;
1430
+ if (restored)
1431
+ await restored.finished.promise;
1432
+ return;
1433
+ }
1158
1434
  const active = current;
1159
1435
  if (!active)
1160
1436
  return;
@@ -1182,6 +1458,70 @@ export function createNestedPlaybookBridge(options) {
1182
1458
  return {
1183
1459
  actorLogic,
1184
1460
  getPendingCall: () => pendingIdentity(current),
1461
+ getSuspendedCall: () => suspendedIdentity(current),
1462
+ prepareRestore(call) {
1463
+ // Capture the complete host-owned descriptor before observing or
1464
+ // mutating bridge state, so a rejected preparation cannot leave state.
1465
+ const captured = call === undefined
1466
+ ? undefined
1467
+ : snapshotSuspendedCall(call, 'restored playbook call');
1468
+ if (disposed) {
1469
+ rejectControlPlane(new Error('nested playbook bridge is disposed'));
1470
+ }
1471
+ if (current) {
1472
+ rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
1473
+ }
1474
+ if (restoreMode) {
1475
+ rejectControlPlane(new Error('nested playbook bridge restore is already prepared'));
1476
+ }
1477
+ if (captured && usedCallIds.has(captured.callId)) {
1478
+ rejectControlPlane(new Error(`restored duplicate playbook call id ${captured.callId}`));
1479
+ }
1480
+ restoreMode = {
1481
+ ...(captured === undefined ? {} : { call: captured }),
1482
+ state: 'armed',
1483
+ };
1484
+ },
1485
+ confirmRestore() {
1486
+ const mode = restoreMode;
1487
+ if (!mode) {
1488
+ throw new Error('nested playbook bridge restore is not prepared');
1489
+ }
1490
+ if (mode.state === 'failed') {
1491
+ restoreMode = undefined;
1492
+ throw mode.error;
1493
+ }
1494
+ if (mode.call === undefined) {
1495
+ restoreMode = undefined;
1496
+ return;
1497
+ }
1498
+ if (mode.state !== 'claimed' || !mode.active) {
1499
+ const error = new Error(`restored playbook call ${mode.call.callId} was not claimed by actor startup`);
1500
+ restoreMode = undefined;
1501
+ reportControlPlaneError(error);
1502
+ throw error;
1503
+ }
1504
+ const active = mode.active;
1505
+ if (active.signal.aborted) {
1506
+ const error = active.signal.reason ??
1507
+ new Error('Restored nested playbook invocation aborted');
1508
+ rollbackRestoredCall(mode, error);
1509
+ restoreMode = undefined;
1510
+ throw error;
1511
+ }
1512
+ try {
1513
+ detachAbortListener(active);
1514
+ active.phase = 'suspended';
1515
+ restoreMode = undefined;
1516
+ publishSuspendedCall(active);
1517
+ }
1518
+ catch (error) {
1519
+ rollbackRestoredCall(mode, error);
1520
+ restoreMode = undefined;
1521
+ reportControlPlaneError(error);
1522
+ throw error;
1523
+ }
1524
+ },
1185
1525
  subscribePendingCall(listener) {
1186
1526
  if (disposed)
1187
1527
  return () => undefined;
@@ -1237,6 +1577,7 @@ export function createNestedPlaybookBridge(options) {
1237
1577
  }
1238
1578
  }
1239
1579
  finally {
1580
+ restoreMode = undefined;
1240
1581
  pendingListeners.clear();
1241
1582
  }
1242
1583
  },