@sublang/playbook 6.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.
@@ -526,58 +526,135 @@ const SNAPSHOT_SEQUENCE_KEYS = [
526
526
  'playerCall',
527
527
  'playbookCall',
528
528
  ];
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)) {
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)) {
534
557
  throw new TypeError('runtime snapshot must be an object');
535
558
  }
536
- if (value.schemaVersion !== 1) {
537
- 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
+ }
538
592
  }
539
- const playbookId = requireNonEmptyString(value.playbookId, 'runtime snapshot playbookId');
593
+ const playbookId = requireNonEmptyString(snapshot.playbookId, 'runtime snapshot playbookId');
540
594
  if (playbookId !== expectedPlaybookId) {
541
595
  throw new TypeError(`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`);
542
596
  }
543
- if (!isRecord(value.machine)) {
597
+ if (!isRecord(snapshot.machine)) {
544
598
  throw new TypeError('runtime snapshot machine must be an object');
545
599
  }
546
- const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
547
- if (!isRecord(value.playerResumeTokens)) {
600
+ const machine = snapshot.machine;
601
+ if (!isRecord(snapshot.playerResumeTokens)) {
548
602
  throw new TypeError('runtime snapshot playerResumeTokens must be an object');
549
603
  }
550
604
  const playerResumeTokens = {};
551
- for (const [playerId, token] of Object.entries(value.playerResumeTokens)) {
605
+ for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
552
606
  defineEnumerableDataProperty(playerResumeTokens, playerId, requireNonEmptyString(token, `runtime snapshot playerResumeTokens.${playerId}`));
553
607
  }
554
- if (!isRecord(value.sequences)) {
608
+ if (!isRecord(snapshot.sequences)) {
555
609
  throw new TypeError('runtime snapshot sequences must be an object');
556
610
  }
611
+ rejectUnknownKeys(snapshot.sequences, [...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'], 'runtime snapshot sequences');
557
612
  const sequences = {};
558
613
  for (const key of SNAPSHOT_SEQUENCE_KEYS) {
559
- const sequence = value.sequences[key];
614
+ const sequence = snapshot.sequences[key];
560
615
  if (!Number.isSafeInteger(sequence) || sequence < 0) {
561
616
  throw new TypeError(`runtime snapshot sequences.${key} must be a non-negative integer`);
562
617
  }
563
618
  sequences[key] = sequence;
564
619
  }
565
- const captainCall = value.sequences.captainCall;
620
+ const captainCall = snapshot.sequences.captainCall;
566
621
  if (captainCall !== undefined) {
567
622
  if (!Number.isSafeInteger(captainCall) || captainCall < 0) {
568
623
  throw new TypeError('runtime snapshot sequences.captainCall must be a non-negative integer');
569
624
  }
570
625
  sequences.captainCall = captainCall;
571
626
  }
572
- validateState(value.state, 'runtime snapshot state');
573
- const state = snapshotJsonValue(value.state, 'runtime snapshot state');
574
- 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)) {
575
651
  throw new TypeError('runtime snapshot pendingBossQuestions must be an array');
576
652
  }
577
- const pendingBossQuestions = value.pendingBossQuestions.map((entry, index) => {
653
+ const pendingBossQuestions = snapshot.pendingBossQuestions.map((entry, index) => {
578
654
  const path = `runtime snapshot pendingBossQuestions[${index}]`;
579
655
  if (!isRecord(entry))
580
656
  throw new TypeError(`${path} must be an object`);
657
+ rejectUnknownKeys(entry, ['questionId', 'player', 'question', 'sourceItem'], path);
581
658
  const question = {
582
659
  questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
583
660
  player: requireNonEmptyString(entry.player, `${path}.player`),
@@ -590,14 +667,21 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
590
667
  };
591
668
  return Object.freeze(question);
592
669
  });
593
- return Object.freeze({
594
- schemaVersion: 1,
670
+ const fields = {
595
671
  playbookId,
596
672
  machine,
597
673
  playerResumeTokens: Object.freeze(playerResumeTokens),
598
674
  sequences: Object.freeze(sequences),
599
675
  state,
600
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 }),
601
685
  });
602
686
  }
603
687
  export class NestedPlaybookCallError extends Error {
@@ -821,6 +905,7 @@ function outputOrThrow(result) {
821
905
  }
822
906
  export function createNestedPlaybookBridge(options) {
823
907
  let current;
908
+ let restoreMode;
824
909
  let disposed = false;
825
910
  const usedCallIds = new Set();
826
911
  const pendingListeners = new Set();
@@ -853,10 +938,29 @@ export function createNestedPlaybookBridge(options) {
853
938
  childSessionId: active.childSessionId,
854
939
  }
855
940
  : undefined;
856
- 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) => {
857
957
  if (active.abortListener) {
858
958
  active.signal.removeEventListener('abort', active.abortListener);
959
+ active.abortListener = undefined;
859
960
  }
961
+ };
962
+ const clear = (active) => {
963
+ detachAbortListener(active);
860
964
  if (current === active)
861
965
  current = undefined;
862
966
  };
@@ -972,23 +1076,171 @@ export function createNestedPlaybookBridge(options) {
972
1076
  throw error;
973
1077
  }
974
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
+ };
975
1126
  const actorLogic = fromPromise(async ({ input, signal: invocationSignal }) => {
976
1127
  if (disposed) {
977
1128
  rejectControlPlane(new Error('nested playbook bridge is disposed'));
978
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
+ }
979
1238
  if (current) {
980
1239
  rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
981
1240
  }
982
- const [normalizedInput, callId] = (() => {
1241
+ const callId = (() => {
983
1242
  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
- ];
1243
+ return requireNonEmptyString(options.nextCallId(), 'allocated playbook call id');
992
1244
  }
993
1245
  catch (error) {
994
1246
  return rejectControlPlane(error);
@@ -1119,32 +1371,7 @@ export function createNestedPlaybookBridge(options) {
1119
1371
  }
1120
1372
  active.phase = 'suspended';
1121
1373
  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;
1374
+ return await waitOnSuspendedCall(active);
1148
1375
  }
1149
1376
  catch (error) {
1150
1377
  active.runError = error;
@@ -1155,6 +1382,16 @@ export function createNestedPlaybookBridge(options) {
1155
1382
  }
1156
1383
  });
1157
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
+ }
1158
1395
  const active = current;
1159
1396
  if (!active)
1160
1397
  return;
@@ -1182,6 +1419,70 @@ export function createNestedPlaybookBridge(options) {
1182
1419
  return {
1183
1420
  actorLogic,
1184
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
+ },
1185
1486
  subscribePendingCall(listener) {
1186
1487
  if (disposed)
1187
1488
  return () => undefined;
@@ -1237,6 +1538,7 @@ export function createNestedPlaybookBridge(options) {
1237
1538
  }
1238
1539
  }
1239
1540
  finally {
1541
+ restoreMode = undefined;
1240
1542
  pendingListeners.clear();
1241
1543
  }
1242
1544
  },