@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.
@@ -1285,8 +1285,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
1285
1285
  // DR-029: the last event a public Boss boundary sent into the
1286
1286
  // machine — classified, deterministic entry, or Boss reply — kept with
1287
1287
  // its recorded payload so a failure-state retry action can replay the
1288
- // event that drove the run into `failed`. Process-local: the schema-1
1289
- // parked snapshot does not persist it (PBRT-50: no schema bump).
1288
+ // event that drove the run into `failed`. Process-local: the durable
1289
+ // runtime snapshot does not persist it (PBRT-50).
1290
1290
  let lastBossEvent;
1291
1291
  // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1292
1292
  // recorded for each idempotency key, returned verbatim on a repeated
@@ -2424,18 +2424,42 @@ export function createXStatePlaybookRuntime(machine, spec) {
2424
2424
  initInFlight = undefined;
2425
2425
  }
2426
2426
  },
2427
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
2427
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
2428
+ // session, including one already-started suspended nested call.
2428
2429
  // Defined only at a safe capture point — initialized, not disposing
2429
- // or disposed, no active public boundary, no pending nested call,
2430
- // and the actor quiescent with status `active`.
2430
+ // or disposed, no active public boundary, and the actor quiescent with
2431
+ // status `active`.
2431
2432
  exportSnapshot() {
2432
2433
  if (!actor || !session || disposed || disposalPromise !== undefined) {
2433
2434
  return undefined;
2434
2435
  }
2435
2436
  if (activeSignal !== undefined)
2436
2437
  return undefined;
2437
- if (nestedBridge.getPendingCall())
2438
+ const pendingCall = nestedBridge.getPendingCall();
2439
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
2440
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
2438
2441
  return undefined;
2442
+ }
2443
+ let suspendedCall;
2444
+ if (bridgeSuspendedCall !== undefined) {
2445
+ if (pendingCall?.callId !== bridgeSuspendedCall.callId ||
2446
+ pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
2447
+ pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId) {
2448
+ return undefined;
2449
+ }
2450
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
2451
+ return undefined;
2452
+ }
2453
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
2454
+ if (bridgeSuspendedCall.turnId !== undefined &&
2455
+ bridgeSuspendedCall.turnId !== turnId) {
2456
+ return undefined;
2457
+ }
2458
+ suspendedCall = {
2459
+ ...bridgeSuspendedCall,
2460
+ ...(turnId === undefined ? {} : { turnId }),
2461
+ };
2462
+ }
2439
2463
  const state = currentState();
2440
2464
  if (state.status !== 'active' || !state.quiescent)
2441
2465
  return undefined;
@@ -2444,7 +2468,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2444
2468
  .context;
2445
2469
  const pending = pendingBossQuestionFromContext(context ?? {});
2446
2470
  return {
2447
- schemaVersion: 1,
2471
+ schemaVersion: 2,
2448
2472
  playbookId: session.playbookId,
2449
2473
  machine: machineSnapshot,
2450
2474
  playerResumeTokens: snapshotPlayerResumeTokens(),
@@ -2469,6 +2493,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2469
2493
  sourceItem: pending.sourceItem,
2470
2494
  },
2471
2495
  ],
2496
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
2472
2497
  };
2473
2498
  },
2474
2499
  // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
@@ -2481,7 +2506,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2481
2506
  throw new Error('createPlaybookRuntime.restore: already initialized');
2482
2507
  }
2483
2508
  const boundSession = snapshotPlaybookSession(nextSession);
2484
- const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
2509
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId, { allowSuspendedCall: true });
2510
+ const suspendedCall = boundSnapshot.schemaVersion === 2
2511
+ ? boundSnapshot.suspendedCall
2512
+ : undefined;
2485
2513
  let priorExternalPlayerTokens;
2486
2514
  let externalStoreRestoreAttempted = false;
2487
2515
  initialized = true;
@@ -2505,26 +2533,48 @@ export function createXStatePlaybookRuntime(machine, spec) {
2505
2533
  // Every Captain call already consumed at least one trace number,
2506
2534
  // so the global trace counter is a collision-safe id floor.
2507
2535
  boundSnapshot.sequences.trace;
2508
- // The schema-1 snapshot carries no apply counter (PBRT-50: no
2509
- // schema bump); every apply boundary consumed trace numbers, so
2510
- // the persisted trace counter is a collision-safe id floor here
2511
- // too, keeping `apply-<n>` call ids unique across restore.
2536
+ // The runtime snapshot carries no apply counter (PBRT-50); every
2537
+ // apply boundary consumed trace numbers, so the persisted trace
2538
+ // counter is a collision-safe id floor here too, keeping
2539
+ // `apply-<n>` call ids unique across restore.
2512
2540
  applyCallSequence = boundSnapshot.sequences.trace;
2513
2541
  if (boundSession.playerSessions) {
2514
2542
  priorExternalPlayerTokens = snapshotPlayerResumeTokens();
2515
2543
  externalStoreRestoreAttempted = true;
2516
2544
  }
2517
2545
  restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
2546
+ nestedBridge.prepareRestore(suspendedCall);
2547
+ if (suspendedCall !== undefined) {
2548
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
2549
+ }
2518
2550
  suppressInspectionEmissions = true;
2519
2551
  actor = buildActor(runtimePorts, boundSnapshot.machine);
2520
2552
  actor.start();
2521
- const restoredState = currentState();
2553
+ if (controlPlaneError !== undefined)
2554
+ throw controlPlaneError;
2555
+ const restoredState = normalizePlaybookSnapshot(actor.getSnapshot(), suspendedCall === undefined
2556
+ ? {}
2557
+ : {
2558
+ pendingCall: {
2559
+ callId: suspendedCall.callId,
2560
+ playbookId: suspendedCall.playbookId,
2561
+ childSessionId: suspendedCall.childSessionId,
2562
+ },
2563
+ });
2522
2564
  if (restoredState.status !== 'active') {
2523
2565
  throw new Error(`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`);
2524
2566
  }
2525
- suppressInspectionEmissions = false;
2567
+ if (stableJson(restoredState, 'restored runtime state') !==
2568
+ stableJson(boundSnapshot.state, 'runtime snapshot state')) {
2569
+ throw new Error('createPlaybookRuntime.restore: restored actor state does not match snapshot state');
2570
+ }
2526
2571
  priorState = restoredState;
2527
2572
  await drainEmissions();
2573
+ suppressInspectionEmissions = false;
2574
+ // Final fallible step: after this publication the authoritative
2575
+ // child has rejoined ordinary resume/abort ownership, so no later
2576
+ // restore validation may trigger failed-start rollback.
2577
+ nestedBridge.confirmRestore();
2528
2578
  })();
2529
2579
  try {
2530
2580
  await initTask;
@@ -1968,8 +1968,8 @@ export function createXStatePlaybookRuntime<TOptions>(
1968
1968
  // DR-029: the last event a public Boss boundary sent into the
1969
1969
  // machine — classified, deterministic entry, or Boss reply — kept with
1970
1970
  // its recorded payload so a failure-state retry action can replay the
1971
- // event that drove the run into `failed`. Process-local: the schema-1
1972
- // parked snapshot does not persist it (PBRT-50: no schema bump).
1971
+ // event that drove the run into `failed`. Process-local: the durable
1972
+ // runtime snapshot does not persist it (PBRT-50).
1973
1973
  let lastBossEvent: EventObject | undefined;
1974
1974
  // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1975
1975
  // recorded for each idempotency key, returned verbatim on a repeated
@@ -3360,16 +3360,45 @@ export function createXStatePlaybookRuntime<TOptions>(
3360
3360
  }
3361
3361
  },
3362
3362
 
3363
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
3363
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
3364
+ // session, including one already-started suspended nested call.
3364
3365
  // Defined only at a safe capture point — initialized, not disposing
3365
- // or disposed, no active public boundary, no pending nested call,
3366
- // and the actor quiescent with status `active`.
3366
+ // or disposed, no active public boundary, and the actor quiescent with
3367
+ // status `active`.
3367
3368
  exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
3368
3369
  if (!actor || !session || disposed || disposalPromise !== undefined) {
3369
3370
  return undefined;
3370
3371
  }
3371
3372
  if (activeSignal !== undefined) return undefined;
3372
- if (nestedBridge.getPendingCall()) return undefined;
3373
+ const pendingCall = nestedBridge.getPendingCall();
3374
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
3375
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
3376
+ return undefined;
3377
+ }
3378
+ let suspendedCall: typeof bridgeSuspendedCall;
3379
+ if (bridgeSuspendedCall !== undefined) {
3380
+ if (
3381
+ pendingCall?.callId !== bridgeSuspendedCall.callId ||
3382
+ pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
3383
+ pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId
3384
+ ) {
3385
+ return undefined;
3386
+ }
3387
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
3388
+ return undefined;
3389
+ }
3390
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
3391
+ if (
3392
+ bridgeSuspendedCall.turnId !== undefined &&
3393
+ bridgeSuspendedCall.turnId !== turnId
3394
+ ) {
3395
+ return undefined;
3396
+ }
3397
+ suspendedCall = {
3398
+ ...bridgeSuspendedCall,
3399
+ ...(turnId === undefined ? {} : { turnId }),
3400
+ };
3401
+ }
3373
3402
  const state = currentState();
3374
3403
  if (state.status !== 'active' || !state.quiescent) return undefined;
3375
3404
  const machineSnapshot = detachPersistedMachineSnapshot(
@@ -3379,7 +3408,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3379
3408
  .context as Record<string, unknown>;
3380
3409
  const pending = pendingBossQuestionFromContext(context ?? {});
3381
3410
  return {
3382
- schemaVersion: 1,
3411
+ schemaVersion: 2,
3383
3412
  playbookId: session.playbookId,
3384
3413
  machine: machineSnapshot,
3385
3414
  playerResumeTokens: snapshotPlayerResumeTokens(),
@@ -3405,6 +3434,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3405
3434
  sourceItem: pending.sourceItem,
3406
3435
  },
3407
3436
  ],
3437
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
3408
3438
  };
3409
3439
  },
3410
3440
 
@@ -3424,7 +3454,12 @@ export function createXStatePlaybookRuntime<TOptions>(
3424
3454
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
3425
3455
  snapshot,
3426
3456
  boundSession.playbookId,
3457
+ { allowSuspendedCall: true },
3427
3458
  );
3459
+ const suspendedCall =
3460
+ boundSnapshot.schemaVersion === 2
3461
+ ? boundSnapshot.suspendedCall
3462
+ : undefined;
3428
3463
  let priorExternalPlayerTokens:
3429
3464
  | Readonly<Record<string, string>>
3430
3465
  | undefined;
@@ -3450,28 +3485,59 @@ export function createXStatePlaybookRuntime<TOptions>(
3450
3485
  // Every Captain call already consumed at least one trace number,
3451
3486
  // so the global trace counter is a collision-safe id floor.
3452
3487
  boundSnapshot.sequences.trace;
3453
- // The schema-1 snapshot carries no apply counter (PBRT-50: no
3454
- // schema bump); every apply boundary consumed trace numbers, so
3455
- // the persisted trace counter is a collision-safe id floor here
3456
- // too, keeping `apply-<n>` call ids unique across restore.
3488
+ // The runtime snapshot carries no apply counter (PBRT-50); every
3489
+ // apply boundary consumed trace numbers, so the persisted trace
3490
+ // counter is a collision-safe id floor here too, keeping
3491
+ // `apply-<n>` call ids unique across restore.
3457
3492
  applyCallSequence = boundSnapshot.sequences.trace;
3458
3493
  if (boundSession.playerSessions) {
3459
3494
  priorExternalPlayerTokens = snapshotPlayerResumeTokens();
3460
3495
  externalStoreRestoreAttempted = true;
3461
3496
  }
3462
3497
  restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
3498
+ nestedBridge.prepareRestore(suspendedCall);
3499
+ if (suspendedCall !== undefined) {
3500
+ playbookCallTurnIds.set(
3501
+ suspendedCall.callId,
3502
+ suspendedCall.turnId,
3503
+ );
3504
+ }
3463
3505
  suppressInspectionEmissions = true;
3464
3506
  actor = buildActor(runtimePorts, boundSnapshot.machine);
3465
3507
  actor.start();
3466
- const restoredState = currentState();
3508
+ if (controlPlaneError !== undefined) throw controlPlaneError;
3509
+ const restoredState = normalizePlaybookSnapshot(
3510
+ actor.getSnapshot(),
3511
+ suspendedCall === undefined
3512
+ ? {}
3513
+ : {
3514
+ pendingCall: {
3515
+ callId: suspendedCall.callId,
3516
+ playbookId: suspendedCall.playbookId,
3517
+ childSessionId: suspendedCall.childSessionId,
3518
+ },
3519
+ },
3520
+ );
3467
3521
  if (restoredState.status !== 'active') {
3468
3522
  throw new Error(
3469
3523
  `createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`,
3470
3524
  );
3471
3525
  }
3472
- suppressInspectionEmissions = false;
3526
+ if (
3527
+ stableJson(restoredState, 'restored runtime state') !==
3528
+ stableJson(boundSnapshot.state, 'runtime snapshot state')
3529
+ ) {
3530
+ throw new Error(
3531
+ 'createPlaybookRuntime.restore: restored actor state does not match snapshot state',
3532
+ );
3533
+ }
3473
3534
  priorState = restoredState;
3474
3535
  await drainEmissions();
3536
+ suppressInspectionEmissions = false;
3537
+ // Final fallible step: after this publication the authoritative
3538
+ // child has rejoined ordinary resume/abort ownership, so no later
3539
+ // restore validation may trigger failed-start rollback.
3540
+ nestedBridge.confirmRestore();
3475
3541
  })();
3476
3542
  try {
3477
3543
  await initTask;
@@ -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;