@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
@@ -248,6 +248,26 @@ function capturedPort(descriptors, name) {
248
248
  }
249
249
  return value;
250
250
  }
251
+ function capturedSessionStore(descriptors) {
252
+ const captured = capturedDataValue(descriptors, 'playerSessions', 'playbook session playerSessions');
253
+ if (!isRecord(captured)) {
254
+ throw new TypeError('playbook session playerSessions must be an object');
255
+ }
256
+ const storeDescriptors = Object.getOwnPropertyDescriptors(captured);
257
+ const method = (name) => {
258
+ const value = capturedDataValue(storeDescriptors, name, `playbook session playerSessions.${name}`);
259
+ if (typeof value !== 'function') {
260
+ throw new TypeError(`playbook session playerSessions.${name} must be a function`);
261
+ }
262
+ return value;
263
+ };
264
+ return Object.freeze({
265
+ select: method('select'),
266
+ update: method('update'),
267
+ snapshot: method('snapshot'),
268
+ restore: method('restore'),
269
+ });
270
+ }
251
271
  /** Validate session causality and detach its immutable identity from the host. */
252
272
  export function snapshotPlaybookSession(session) {
253
273
  if (!isRecord(session)) {
@@ -266,6 +286,7 @@ export function snapshotPlaybookSession(session) {
266
286
  const capturedParentCallId = capturedDataValue(sessionDescriptors, 'parentCallId', 'playbook session parentCallId', false);
267
287
  const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
268
288
  const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
289
+ const hasPlayerSessions = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'playerSessions');
269
290
  let parentSessionId;
270
291
  let parentCallId;
271
292
  if (depth === 0) {
@@ -296,6 +317,9 @@ export function snapshotPlaybookSession(session) {
296
317
  emitStatus: capturedPort(portDescriptors, 'emitStatus'),
297
318
  emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
298
319
  });
320
+ const playerSessions = hasPlayerSessions
321
+ ? capturedSessionStore(sessionDescriptors)
322
+ : undefined;
299
323
  return Object.freeze({
300
324
  sessionId,
301
325
  playbookId,
@@ -303,6 +327,7 @@ export function snapshotPlaybookSession(session) {
303
327
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
304
328
  ...(parentCallId === undefined ? {} : { parentCallId }),
305
329
  depth,
330
+ ...(playerSessions === undefined ? {} : { playerSessions }),
306
331
  ports,
307
332
  });
308
333
  }
@@ -501,58 +526,135 @@ const SNAPSHOT_SEQUENCE_KEYS = [
501
526
  'playerCall',
502
527
  'playbookCall',
503
528
  ];
504
- // DR-014 §1: validate and detach a host-supplied runtime snapshot before
505
- // restore touches any state. Rejects a schema-version or playbook-id
506
- // mismatch with a path-named error.
507
- export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
508
- if (!isRecord(value)) {
529
+ function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
530
+ const captured = snapshotJsonValue(value, path);
531
+ if (!isRecord(captured)) {
532
+ throw new TypeError(`${path} must be an object`);
533
+ }
534
+ rejectUnknownKeys(captured, ['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'], path);
535
+ const call = {
536
+ callId: requireNonEmptyString(captured.callId, `${path}.callId`),
537
+ stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
538
+ playbookId: requireNonEmptyString(captured.playbookId, `${path}.playbookId`),
539
+ text: requireNonEmptyString(captured.text, `${path}.text`),
540
+ childSessionId: requireNonEmptyString(captured.childSessionId, `${path}.childSessionId`),
541
+ };
542
+ if (own(captured, 'turnId')) {
543
+ if (!Number.isSafeInteger(captured.turnId) ||
544
+ captured.turnId <= 0) {
545
+ throw new TypeError(`${path}.turnId must be a positive integer`);
546
+ }
547
+ call.turnId = captured.turnId;
548
+ }
549
+ return Object.freeze(call);
550
+ }
551
+ // DR-014 §1 / DR-031 §5: validate and detach a host-supplied runtime
552
+ // snapshot before restore touches any state. A suspended schema-2 call is
553
+ // rejected unless the restore path explicitly promises to seed and claim it.
554
+ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options = {}) {
555
+ const snapshot = snapshotJsonValue(value, 'runtime snapshot');
556
+ if (!isRecord(snapshot)) {
509
557
  throw new TypeError('runtime snapshot must be an object');
510
558
  }
511
- if (value.schemaVersion !== 1) {
512
- throw new TypeError(`runtime snapshot schemaVersion ${String(value.schemaVersion)} is not supported (expected 1)`);
559
+ const capturedOptions = snapshotJsonValue(options, 'runtime snapshot validation options');
560
+ if (!isRecord(capturedOptions)) {
561
+ throw new TypeError('runtime snapshot validation options must be an object');
562
+ }
563
+ rejectUnknownKeys(capturedOptions, ['allowSuspendedCall'], 'runtime snapshot validation options');
564
+ if (capturedOptions.allowSuspendedCall !== undefined &&
565
+ typeof capturedOptions.allowSuspendedCall !== 'boolean') {
566
+ throw new TypeError('runtime snapshot validation options.allowSuspendedCall must be boolean');
567
+ }
568
+ const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
569
+ if (snapshot.schemaVersion !== 1 && snapshot.schemaVersion !== 2) {
570
+ throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1 or 2)`);
571
+ }
572
+ const schemaVersion = snapshot.schemaVersion;
573
+ rejectUnknownKeys(snapshot, [
574
+ 'schemaVersion',
575
+ 'playbookId',
576
+ 'machine',
577
+ 'playerResumeTokens',
578
+ 'sequences',
579
+ 'state',
580
+ 'pendingBossQuestions',
581
+ 'suspendedCall',
582
+ ], 'runtime snapshot');
583
+ if (schemaVersion === 1 && own(snapshot, 'suspendedCall')) {
584
+ throw new TypeError('runtime snapshot schemaVersion 1 must not carry suspendedCall');
585
+ }
586
+ let suspendedCall;
587
+ if (schemaVersion === 2 && own(snapshot, 'suspendedCall')) {
588
+ suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
589
+ if (!allowSuspendedCall) {
590
+ throw new TypeError('runtime snapshot suspendedCall requires a restore path that explicitly allows it');
591
+ }
513
592
  }
514
- const playbookId = requireNonEmptyString(value.playbookId, 'runtime snapshot playbookId');
593
+ const playbookId = requireNonEmptyString(snapshot.playbookId, 'runtime snapshot playbookId');
515
594
  if (playbookId !== expectedPlaybookId) {
516
595
  throw new TypeError(`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`);
517
596
  }
518
- if (!isRecord(value.machine)) {
597
+ if (!isRecord(snapshot.machine)) {
519
598
  throw new TypeError('runtime snapshot machine must be an object');
520
599
  }
521
- const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
522
- if (!isRecord(value.playerResumeTokens)) {
600
+ const machine = snapshot.machine;
601
+ if (!isRecord(snapshot.playerResumeTokens)) {
523
602
  throw new TypeError('runtime snapshot playerResumeTokens must be an object');
524
603
  }
525
604
  const playerResumeTokens = {};
526
- for (const [playerId, token] of Object.entries(value.playerResumeTokens)) {
605
+ for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
527
606
  defineEnumerableDataProperty(playerResumeTokens, playerId, requireNonEmptyString(token, `runtime snapshot playerResumeTokens.${playerId}`));
528
607
  }
529
- if (!isRecord(value.sequences)) {
608
+ if (!isRecord(snapshot.sequences)) {
530
609
  throw new TypeError('runtime snapshot sequences must be an object');
531
610
  }
611
+ rejectUnknownKeys(snapshot.sequences, [...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'], 'runtime snapshot sequences');
532
612
  const sequences = {};
533
613
  for (const key of SNAPSHOT_SEQUENCE_KEYS) {
534
- const sequence = value.sequences[key];
614
+ const sequence = snapshot.sequences[key];
535
615
  if (!Number.isSafeInteger(sequence) || sequence < 0) {
536
616
  throw new TypeError(`runtime snapshot sequences.${key} must be a non-negative integer`);
537
617
  }
538
618
  sequences[key] = sequence;
539
619
  }
540
- const captainCall = value.sequences.captainCall;
620
+ const captainCall = snapshot.sequences.captainCall;
541
621
  if (captainCall !== undefined) {
542
622
  if (!Number.isSafeInteger(captainCall) || captainCall < 0) {
543
623
  throw new TypeError('runtime snapshot sequences.captainCall must be a non-negative integer');
544
624
  }
545
625
  sequences.captainCall = captainCall;
546
626
  }
547
- validateState(value.state, 'runtime snapshot state');
548
- const state = snapshotJsonValue(value.state, 'runtime snapshot state');
549
- if (!Array.isArray(value.pendingBossQuestions)) {
627
+ validateState(snapshot.state, 'runtime snapshot state');
628
+ const state = snapshot.state;
629
+ if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
630
+ throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires schemaVersion 2 suspendedCall`);
631
+ }
632
+ if (suspendedCall) {
633
+ if (sequences.playbookCall === 0) {
634
+ throw new TypeError('runtime snapshot suspendedCall requires sequences.playbookCall greater than zero');
635
+ }
636
+ if (suspendedCall.turnId !== undefined &&
637
+ suspendedCall.turnId > sequences.turn) {
638
+ throw new TypeError('runtime snapshot suspendedCall.turnId must not exceed sequences.turn');
639
+ }
640
+ if (state.status !== 'active' || !state.quiescent) {
641
+ throw new TypeError('runtime snapshot suspendedCall requires an active quiescent state');
642
+ }
643
+ if (!state.tags.includes(SUSPENDED_TAG)) {
644
+ throw new TypeError(`runtime snapshot suspendedCall requires state tag ${SUSPENDED_TAG}`);
645
+ }
646
+ if (!state.activeStateIds.includes(suspendedCall.stateId)) {
647
+ throw new TypeError('runtime snapshot suspendedCall.stateId must be active in snapshot state');
648
+ }
649
+ }
650
+ if (!Array.isArray(snapshot.pendingBossQuestions)) {
550
651
  throw new TypeError('runtime snapshot pendingBossQuestions must be an array');
551
652
  }
552
- const pendingBossQuestions = value.pendingBossQuestions.map((entry, index) => {
653
+ const pendingBossQuestions = snapshot.pendingBossQuestions.map((entry, index) => {
553
654
  const path = `runtime snapshot pendingBossQuestions[${index}]`;
554
655
  if (!isRecord(entry))
555
656
  throw new TypeError(`${path} must be an object`);
657
+ rejectUnknownKeys(entry, ['questionId', 'player', 'question', 'sourceItem'], path);
556
658
  const question = {
557
659
  questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
558
660
  player: requireNonEmptyString(entry.player, `${path}.player`),
@@ -565,14 +667,21 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
565
667
  };
566
668
  return Object.freeze(question);
567
669
  });
568
- return Object.freeze({
569
- schemaVersion: 1,
670
+ const fields = {
570
671
  playbookId,
571
672
  machine,
572
673
  playerResumeTokens: Object.freeze(playerResumeTokens),
573
674
  sequences: Object.freeze(sequences),
574
675
  state,
575
676
  pendingBossQuestions: Object.freeze(pendingBossQuestions),
677
+ };
678
+ if (schemaVersion === 1) {
679
+ return Object.freeze({ schemaVersion: 1, ...fields });
680
+ }
681
+ return Object.freeze({
682
+ schemaVersion: 2,
683
+ ...fields,
684
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
576
685
  });
577
686
  }
578
687
  export class NestedPlaybookCallError extends Error {
@@ -796,6 +905,7 @@ function outputOrThrow(result) {
796
905
  }
797
906
  export function createNestedPlaybookBridge(options) {
798
907
  let current;
908
+ let restoreMode;
799
909
  let disposed = false;
800
910
  const usedCallIds = new Set();
801
911
  const pendingListeners = new Set();
@@ -828,10 +938,29 @@ export function createNestedPlaybookBridge(options) {
828
938
  childSessionId: active.childSessionId,
829
939
  }
830
940
  : undefined;
831
- const clear = (active) => {
941
+ const suspendedIdentity = (active) => active?.phase === 'suspended' && active.childSessionId
942
+ ? Object.freeze({
943
+ callId: active.callId,
944
+ stateId: active.input.stateId,
945
+ playbookId: active.input.playbookId,
946
+ text: active.input.text,
947
+ childSessionId: active.childSessionId,
948
+ ...(active.turnId === undefined ? {} : { turnId: active.turnId }),
949
+ })
950
+ : undefined;
951
+ const failRestoreMode = (mode, error) => {
952
+ mode.state = 'failed';
953
+ mode.error = error;
954
+ reportControlPlaneError(error);
955
+ };
956
+ const detachAbortListener = (active) => {
832
957
  if (active.abortListener) {
833
958
  active.signal.removeEventListener('abort', active.abortListener);
959
+ active.abortListener = undefined;
834
960
  }
961
+ };
962
+ const clear = (active) => {
963
+ detachAbortListener(active);
835
964
  if (current === active)
836
965
  current = undefined;
837
966
  };
@@ -947,23 +1076,171 @@ export function createNestedPlaybookBridge(options) {
947
1076
  throw error;
948
1077
  }
949
1078
  };
1079
+ const rollbackRestoredCall = (mode, error) => {
1080
+ const active = mode.active;
1081
+ mode.state = 'failed';
1082
+ mode.error = error;
1083
+ mode.active = undefined;
1084
+ if (!active)
1085
+ return undefined;
1086
+ active.phase = 'settling';
1087
+ active.restoreRolledBack = true;
1088
+ clear(active);
1089
+ usedCallIds.delete(active.callId);
1090
+ active.deferred.reject(error);
1091
+ return active;
1092
+ };
1093
+ const publishSuspendedCall = (active) => {
1094
+ if (active.phase !== 'suspended') {
1095
+ throw new Error(`playbook call ${active.callId} is not suspended`);
1096
+ }
1097
+ const abortListener = () => {
1098
+ if (active.phase !== 'suspended')
1099
+ return;
1100
+ const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ?? new Error('Nested playbook invocation aborted'), true);
1101
+ void settlePending(active, result).catch((error) => {
1102
+ reportBackgroundError(error);
1103
+ });
1104
+ };
1105
+ active.abortListener = abortListener;
1106
+ active.signal.addEventListener('abort', abortListener, { once: true });
1107
+ const pendingCall = pendingIdentity(active);
1108
+ if (!pendingCall) {
1109
+ throw new Error('suspended call identity was not recorded');
1110
+ }
1111
+ for (const listener of pendingListeners) {
1112
+ try {
1113
+ listener(pendingCall);
1114
+ }
1115
+ catch (error) {
1116
+ reportBackgroundError(error);
1117
+ }
1118
+ }
1119
+ if (active.signal.aborted)
1120
+ abortListener();
1121
+ };
1122
+ const waitOnSuspendedCall = async (active) => {
1123
+ publishSuspendedCall(active);
1124
+ return await active.deferred.promise;
1125
+ };
950
1126
  const actorLogic = fromPromise(async ({ input, signal: invocationSignal }) => {
951
1127
  if (disposed) {
952
1128
  rejectControlPlane(new Error('nested playbook bridge is disposed'));
953
1129
  }
1130
+ const normalizedInput = (() => {
1131
+ try {
1132
+ return {
1133
+ stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
1134
+ playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
1135
+ text: requireNonEmptyString(input.text, 'playbook input text'),
1136
+ };
1137
+ }
1138
+ catch (error) {
1139
+ const mode = restoreMode;
1140
+ if (mode) {
1141
+ if (mode.state === 'claimed') {
1142
+ rollbackRestoredCall(mode, error);
1143
+ reportControlPlaneError(error);
1144
+ }
1145
+ else
1146
+ failRestoreMode(mode, error);
1147
+ throw error;
1148
+ }
1149
+ return rejectControlPlane(error);
1150
+ }
1151
+ })();
1152
+ const mode = restoreMode;
1153
+ if (mode) {
1154
+ if (mode.state !== 'armed') {
1155
+ const callId = mode.call?.callId ?? 'without a descriptor';
1156
+ const error = new Error(mode.state === 'claimed'
1157
+ ? `restored playbook call ${callId} was claimed more than once`
1158
+ : `restored playbook call ${callId} is no longer claimable`);
1159
+ if (mode.state === 'claimed')
1160
+ rollbackRestoredCall(mode, error);
1161
+ else
1162
+ mode.error ??= error;
1163
+ reportControlPlaneError(error);
1164
+ throw error;
1165
+ }
1166
+ const seed = mode.call;
1167
+ if (!seed) {
1168
+ const error = new Error('restored machine invoked a nested playbook without a suspendedCall descriptor');
1169
+ failRestoreMode(mode, error);
1170
+ throw error;
1171
+ }
1172
+ for (const field of ['stateId', 'playbookId', 'text']) {
1173
+ if (normalizedInput[field] !== seed[field]) {
1174
+ const error = new Error(`restored playbook call ${seed.callId} ${field} does not match its persisted input`);
1175
+ failRestoreMode(mode, error);
1176
+ throw error;
1177
+ }
1178
+ }
1179
+ if (usedCallIds.has(seed.callId)) {
1180
+ const error = new Error(`restored duplicate playbook call id ${seed.callId}`);
1181
+ failRestoreMode(mode, error);
1182
+ throw error;
1183
+ }
1184
+ const controller = new AbortController();
1185
+ let callSignal;
1186
+ try {
1187
+ callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
1188
+ }
1189
+ catch (error) {
1190
+ failRestoreMode(mode, error);
1191
+ throw error;
1192
+ }
1193
+ const active = {
1194
+ callId: seed.callId,
1195
+ input: normalizedInput,
1196
+ ...(seed.turnId === undefined
1197
+ ? {}
1198
+ : { turnId: seed.turnId }),
1199
+ deferred: deferred(),
1200
+ finished: deferred(),
1201
+ controller,
1202
+ signal: callSignal,
1203
+ phase: 'restoring',
1204
+ childSessionId: seed.childSessionId,
1205
+ };
1206
+ usedCallIds.add(active.callId);
1207
+ current = active;
1208
+ mode.state = 'claimed';
1209
+ mode.active = active;
1210
+ const restoreAbortListener = () => {
1211
+ if (restoreMode !== mode ||
1212
+ mode.state !== 'claimed' ||
1213
+ mode.active !== active ||
1214
+ active.phase !== 'restoring') {
1215
+ return;
1216
+ }
1217
+ rollbackRestoredCall(mode, active.signal.reason ??
1218
+ new Error('Restored nested playbook invocation aborted'));
1219
+ };
1220
+ active.abortListener = restoreAbortListener;
1221
+ active.signal.addEventListener('abort', restoreAbortListener, {
1222
+ once: true,
1223
+ });
1224
+ if (active.signal.aborted)
1225
+ restoreAbortListener();
1226
+ try {
1227
+ return await active.deferred.promise;
1228
+ }
1229
+ catch (error) {
1230
+ if (!active.restoreRolledBack)
1231
+ active.runError = error;
1232
+ throw error;
1233
+ }
1234
+ finally {
1235
+ active.finished.resolve(undefined);
1236
+ }
1237
+ }
954
1238
  if (current) {
955
1239
  rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
956
1240
  }
957
- const [normalizedInput, callId] = (() => {
1241
+ const callId = (() => {
958
1242
  try {
959
- return [
960
- {
961
- stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
962
- playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
963
- text: requireNonEmptyString(input.text, 'playbook input text'),
964
- },
965
- requireNonEmptyString(options.nextCallId(), 'allocated playbook call id'),
966
- ];
1243
+ return requireNonEmptyString(options.nextCallId(), 'allocated playbook call id');
967
1244
  }
968
1245
  catch (error) {
969
1246
  return rejectControlPlane(error);
@@ -1094,32 +1371,7 @@ export function createNestedPlaybookBridge(options) {
1094
1371
  }
1095
1372
  active.phase = 'suspended';
1096
1373
  active.childSessionId = start.childSessionId;
1097
- const abortListener = () => {
1098
- if (active.phase !== 'suspended')
1099
- return;
1100
- const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
1101
- new Error('Nested playbook invocation aborted'), true);
1102
- void settlePending(active, result).catch((error) => {
1103
- reportBackgroundError(error);
1104
- });
1105
- };
1106
- active.abortListener = abortListener;
1107
- active.signal.addEventListener('abort', abortListener, { once: true });
1108
- const pendingCall = pendingIdentity(active);
1109
- if (!pendingCall) {
1110
- throw new Error('suspended call identity was not recorded');
1111
- }
1112
- for (const listener of pendingListeners) {
1113
- try {
1114
- listener(pendingCall);
1115
- }
1116
- catch (error) {
1117
- reportBackgroundError(error);
1118
- }
1119
- }
1120
- if (active.signal.aborted)
1121
- abortListener();
1122
- return await active.deferred.promise;
1374
+ return await waitOnSuspendedCall(active);
1123
1375
  }
1124
1376
  catch (error) {
1125
1377
  active.runError = error;
@@ -1130,6 +1382,16 @@ export function createNestedPlaybookBridge(options) {
1130
1382
  }
1131
1383
  });
1132
1384
  const abortPending = async (error = new Error('Nested playbook call aborted')) => {
1385
+ const mode = restoreMode;
1386
+ if (mode) {
1387
+ restoreMode = undefined;
1388
+ const restored = mode.state === 'claimed'
1389
+ ? rollbackRestoredCall(mode, error)
1390
+ : undefined;
1391
+ if (restored)
1392
+ await restored.finished.promise;
1393
+ return;
1394
+ }
1133
1395
  const active = current;
1134
1396
  if (!active)
1135
1397
  return;
@@ -1157,6 +1419,70 @@ export function createNestedPlaybookBridge(options) {
1157
1419
  return {
1158
1420
  actorLogic,
1159
1421
  getPendingCall: () => pendingIdentity(current),
1422
+ getSuspendedCall: () => suspendedIdentity(current),
1423
+ prepareRestore(call) {
1424
+ // Capture the complete host-owned descriptor before observing or
1425
+ // mutating bridge state, so a rejected preparation cannot leave state.
1426
+ const captured = call === undefined
1427
+ ? undefined
1428
+ : snapshotSuspendedCall(call, 'restored playbook call');
1429
+ if (disposed) {
1430
+ rejectControlPlane(new Error('nested playbook bridge is disposed'));
1431
+ }
1432
+ if (current) {
1433
+ rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
1434
+ }
1435
+ if (restoreMode) {
1436
+ rejectControlPlane(new Error('nested playbook bridge restore is already prepared'));
1437
+ }
1438
+ if (captured && usedCallIds.has(captured.callId)) {
1439
+ rejectControlPlane(new Error(`restored duplicate playbook call id ${captured.callId}`));
1440
+ }
1441
+ restoreMode = {
1442
+ ...(captured === undefined ? {} : { call: captured }),
1443
+ state: 'armed',
1444
+ };
1445
+ },
1446
+ confirmRestore() {
1447
+ const mode = restoreMode;
1448
+ if (!mode) {
1449
+ throw new Error('nested playbook bridge restore is not prepared');
1450
+ }
1451
+ if (mode.state === 'failed') {
1452
+ restoreMode = undefined;
1453
+ throw mode.error;
1454
+ }
1455
+ if (mode.call === undefined) {
1456
+ restoreMode = undefined;
1457
+ return;
1458
+ }
1459
+ if (mode.state !== 'claimed' || !mode.active) {
1460
+ const error = new Error(`restored playbook call ${mode.call.callId} was not claimed by actor startup`);
1461
+ restoreMode = undefined;
1462
+ reportControlPlaneError(error);
1463
+ throw error;
1464
+ }
1465
+ const active = mode.active;
1466
+ if (active.signal.aborted) {
1467
+ const error = active.signal.reason ??
1468
+ new Error('Restored nested playbook invocation aborted');
1469
+ rollbackRestoredCall(mode, error);
1470
+ restoreMode = undefined;
1471
+ throw error;
1472
+ }
1473
+ try {
1474
+ detachAbortListener(active);
1475
+ active.phase = 'suspended';
1476
+ restoreMode = undefined;
1477
+ publishSuspendedCall(active);
1478
+ }
1479
+ catch (error) {
1480
+ rollbackRestoredCall(mode, error);
1481
+ restoreMode = undefined;
1482
+ reportControlPlaneError(error);
1483
+ throw error;
1484
+ }
1485
+ },
1160
1486
  subscribePendingCall(listener) {
1161
1487
  if (disposed)
1162
1488
  return () => undefined;
@@ -1212,6 +1538,7 @@ export function createNestedPlaybookBridge(options) {
1212
1538
  }
1213
1539
  }
1214
1540
  finally {
1541
+ restoreMode = undefined;
1215
1542
  pendingListeners.clear();
1216
1543
  }
1217
1544
  },