@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
@@ -150,6 +150,12 @@ export interface ScheduledStatus {
150
150
  data?: JsonValue;
151
151
  }
152
152
 
153
+ /** Boss-facing identity for one FSM state whose invoked actor is `player`. */
154
+ export interface XStatePlayerStateStatus {
155
+ player: string;
156
+ label: string;
157
+ }
158
+
153
159
  export interface XStateBossEventFieldSpec {
154
160
  /** The judge supplies routing data; the runtime supplies exact Boss text. */
155
161
  source: 'judge' | 'text';
@@ -391,8 +397,10 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
391
397
  * recoverable FSM-result failures instead.
392
398
  */
393
399
  captainStrategy?: XStateCaptainStrategy<TOptions>;
394
- /** Status line emitted after classification names an event. Default: none. */
400
+ /** Status line emitted after classification; canonical metadata defaults to the event type, legacy artifacts to none. */
395
401
  classificationStatus?: (event: EventObject) => string | undefined;
402
+ /** Complete FSM-derived Boss-facing metadata for every `player` state; its presence selects the canonical status profile. */
403
+ playerStates?: Readonly<Record<string, XStatePlayerStateStatus>>;
396
404
  /** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
397
405
  resolvePlayerId?: (input: PlaybookPlayerInput, options: TOptions) => string;
398
406
  /** Compose the player prompt. Default: continuation blocks + `<field>` placeholder substitution. */
@@ -421,7 +429,7 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
421
429
  controlContextFields?: readonly string[];
422
430
  /** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
423
431
  resumableStateIds?: ReadonlySet<string>;
424
- /** Human status lines for a root transition. Default: entry lines with question/failure surfacing. */
432
+ /** Human status lines for a root transition. Default: guard, declared-player, question, and failure lines. */
425
433
  statusesForState?: (
426
434
  state: PlaybookState,
427
435
  context: Record<string, unknown>,
@@ -1055,6 +1063,60 @@ function collectInvokeSources(machine: AnyStateMachine): ReadonlySet<string> {
1055
1063
  return sources;
1056
1064
  }
1057
1065
 
1066
+ function collectPlayerStatePlayers(
1067
+ machine: AnyStateMachine,
1068
+ ): ReadonlyMap<string, string> {
1069
+ const players = new Map<string, string>();
1070
+ const visit = (stateDef: unknown, stateKey: string): void => {
1071
+ if (!isPlainObject(stateDef)) return;
1072
+ const invoke = stateDef.invoke;
1073
+ const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
1074
+ if (
1075
+ invokes.some(
1076
+ (entry) =>
1077
+ isPlainObject(entry) && entry.src === 'player',
1078
+ )
1079
+ ) {
1080
+ const playbookMeta = isPlainObject(stateDef.meta)
1081
+ ? stateDef.meta.playbook
1082
+ : undefined;
1083
+ const stateId =
1084
+ isPlainObject(playbookMeta) &&
1085
+ typeof playbookMeta.stateId === 'string'
1086
+ ? playbookMeta.stateId
1087
+ : typeof stateDef.id === 'string'
1088
+ ? stateDef.id
1089
+ : stateKey;
1090
+ if (stateId.trim().length === 0) {
1091
+ throw new TypeError(
1092
+ 'player state metadata must use a non-empty state id',
1093
+ );
1094
+ }
1095
+ const player = isPlainObject(playbookMeta)
1096
+ ? playbookMeta.player
1097
+ : undefined;
1098
+ if (typeof player !== 'string' || player.trim().length === 0) {
1099
+ throw new TypeError(
1100
+ `player state ${stateId} meta.playbook.player must be a non-empty string`,
1101
+ );
1102
+ }
1103
+ players.set(stateId, player);
1104
+ }
1105
+ if (isPlainObject(stateDef.states)) {
1106
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
1107
+ visit(child, childKey);
1108
+ }
1109
+ }
1110
+ };
1111
+ const config = (machine as unknown as { config?: unknown }).config;
1112
+ if (isPlainObject(config) && isPlainObject(config.states)) {
1113
+ for (const [stateKey, stateDef] of Object.entries(config.states)) {
1114
+ visit(stateDef, stateKey);
1115
+ }
1116
+ }
1117
+ return players;
1118
+ }
1119
+
1058
1120
  function transitionTargets(transition: unknown): string[] {
1059
1121
  const arms = Array.isArray(transition) ? transition : [transition];
1060
1122
  const targets: string[] = [];
@@ -1206,7 +1268,70 @@ function makeDefaultNormalizeTransitionEvent(
1206
1268
  };
1207
1269
  }
1208
1270
 
1209
- function defaultStatusesForState(
1271
+ function snapshotPlayerStateStatuses(
1272
+ value: Readonly<Record<string, XStatePlayerStateStatus>> | undefined,
1273
+ label: string,
1274
+ machine: AnyStateMachine,
1275
+ stateDescriptions: ReadonlyMap<string, string>,
1276
+ ): ReadonlyMap<string, XStatePlayerStateStatus> {
1277
+ if (value === undefined) return new Map();
1278
+ if (!isPlainObject(value)) {
1279
+ throw new TypeError(`${label} playerStates must be an object`);
1280
+ }
1281
+ const declared = collectPlayerStatePlayers(machine);
1282
+ const statuses = new Map<string, XStatePlayerStateStatus>();
1283
+ for (const [stateId, candidate] of Object.entries(value)) {
1284
+ if (!declared.has(stateId)) {
1285
+ throw new TypeError(
1286
+ `${label} playerStates.${stateId} does not name a player state`,
1287
+ );
1288
+ }
1289
+ if (
1290
+ !isPlainObject(candidate) ||
1291
+ typeof candidate.player !== 'string' ||
1292
+ candidate.player.trim().length === 0 ||
1293
+ typeof candidate.label !== 'string' ||
1294
+ candidate.label.trim().length === 0
1295
+ ) {
1296
+ throw new TypeError(
1297
+ `${label} playerStates.${stateId} must carry non-empty player and label strings`,
1298
+ );
1299
+ }
1300
+ const expectedLabel = stateDescriptions.get(stateId);
1301
+ if (candidate.label !== expectedLabel) {
1302
+ throw new TypeError(
1303
+ `${label} playerStates.${stateId}.label must equal its FSM description`,
1304
+ );
1305
+ }
1306
+ if (candidate.player !== declared.get(stateId)) {
1307
+ throw new TypeError(
1308
+ `${label} playerStates.${stateId}.player must equal its FSM player`,
1309
+ );
1310
+ }
1311
+ statuses.set(stateId, {
1312
+ player: candidate.player,
1313
+ label: candidate.label,
1314
+ });
1315
+ }
1316
+ for (const stateId of declared.keys()) {
1317
+ if (!statuses.has(stateId)) {
1318
+ throw new TypeError(
1319
+ `${label} playerStates must declare player state ${stateId}`,
1320
+ );
1321
+ }
1322
+ }
1323
+ return statuses;
1324
+ }
1325
+
1326
+ function settlingGuard(event: unknown): string | undefined {
1327
+ if (!isPlainObject(event) || !isPlainObject(event.output)) return undefined;
1328
+ const guard = event.output.guard;
1329
+ return typeof guard === 'string' && guard.trim().length > 0
1330
+ ? guard
1331
+ : undefined;
1332
+ }
1333
+
1334
+ function legacyStatusesForState(
1210
1335
  state: PlaybookState,
1211
1336
  context: Record<string, unknown>,
1212
1337
  ): ScheduledStatus[] {
@@ -1214,11 +1339,14 @@ function defaultStatusesForState(
1214
1339
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) return [];
1215
1340
  if (stateId === 'awaitBossReply') {
1216
1341
  const pending = pendingBossQuestionFromContext(context);
1217
- const message =
1218
- pending === undefined
1219
- ? 'Awaiting Boss reply.'
1220
- : `${pending.player} asks: ${pending.question}`;
1221
- return [{ message }];
1342
+ return [
1343
+ {
1344
+ message:
1345
+ pending === undefined
1346
+ ? 'Awaiting Boss reply.'
1347
+ : `${pending.player} asks: ${pending.question}`,
1348
+ },
1349
+ ];
1222
1350
  }
1223
1351
  if (stateId === 'failed') {
1224
1352
  const lastError = normalizeErrorFull(context.lastError);
@@ -1234,6 +1362,60 @@ function defaultStatusesForState(
1234
1362
  return [{ message: `Entered ${stateId}.` }];
1235
1363
  }
1236
1364
 
1365
+ function makeDefaultStatusesForState(
1366
+ playerStates: ReadonlyMap<string, XStatePlayerStateStatus>,
1367
+ ): NonNullable<XStatePlaybookRuntimeSpec<unknown>['statusesForState']> {
1368
+ return (state, context, event): ScheduledStatus[] => {
1369
+ const statuses: ScheduledStatus[] = [];
1370
+ const guard = settlingGuard(event);
1371
+ if (guard !== undefined) statuses.push({ message: `→ ${guard}` });
1372
+
1373
+ const stateId = state.stateId;
1374
+ if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
1375
+ return statuses;
1376
+ }
1377
+ if (stateId === 'awaitBossReply') {
1378
+ const pending = pendingBossQuestionFromContext(context);
1379
+ if (pending === undefined) {
1380
+ return [...statuses, { message: 'Awaiting Boss reply.' }];
1381
+ }
1382
+ return [
1383
+ ...statuses,
1384
+ { message: `${pending.player} asks: ${pending.question}` },
1385
+ {
1386
+ message:
1387
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
1388
+ `${pending.player} · ${pending.sourceItem}`,
1389
+ },
1390
+ ];
1391
+ }
1392
+ if (stateId === 'failed') {
1393
+ const lastError = normalizeErrorCompact(context.lastError);
1394
+ return [
1395
+ ...statuses,
1396
+ {
1397
+ message: '◆ workflow failed; awaiting Boss recovery.',
1398
+ ...(lastError === undefined
1399
+ ? {}
1400
+ : {
1401
+ data: snapshotJsonValue(
1402
+ { lastError },
1403
+ 'failed status data',
1404
+ ),
1405
+ }),
1406
+ },
1407
+ ];
1408
+ }
1409
+ const playerState = playerStates.get(stateId);
1410
+ if (playerState !== undefined) {
1411
+ statuses.push({
1412
+ message: `⤷ ${playerState.player}: ${playerState.label}`,
1413
+ });
1414
+ }
1415
+ return statuses;
1416
+ };
1417
+ }
1418
+
1237
1419
  // ---------------------------------------------------------------------------
1238
1420
  // Default parked-state classifier (slc/link.md §Boss-event mapping): the
1239
1421
  // runtime-owned textual fields are never requested from the judge; only the
@@ -1675,6 +1857,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1675
1857
  // DR-029: source state descriptions label the control actions the
1676
1858
  // runtime advertises through `describe()`.
1677
1859
  const stateDescriptions = stateDescriptionsFromMachine(machine);
1860
+ const hasCanonicalStatusProfile = spec.playerStates !== undefined;
1861
+ const playerStates = snapshotPlayerStateStatuses(
1862
+ spec.playerStates,
1863
+ label,
1864
+ machine,
1865
+ stateDescriptions,
1866
+ );
1678
1867
  // PBRT-52: the artifact's own ControlView context projection. Nothing is
1679
1868
  // exported by default, so an FSM context member — including one added
1680
1869
  // after this artifact was linked — is private until named here. The two
@@ -1728,7 +1917,16 @@ export function createXStatePlaybookRuntime<TOptions>(
1728
1917
  const normalizeTransitionEvent =
1729
1918
  spec.normalizeTransitionEvent ??
1730
1919
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1731
- const statusesForState = spec.statusesForState ?? defaultStatusesForState;
1920
+ const statusesForState =
1921
+ spec.statusesForState ??
1922
+ (hasCanonicalStatusProfile
1923
+ ? makeDefaultStatusesForState(playerStates)
1924
+ : legacyStatusesForState);
1925
+ const classificationStatus =
1926
+ spec.classificationStatus ??
1927
+ (hasCanonicalStatusProfile
1928
+ ? (event: EventObject) => event.type
1929
+ : () => undefined);
1732
1930
  const machineInput =
1733
1931
  spec.machineInput ?? ((options: TOptions) => options as unknown);
1734
1932
  const scriptCwd =
@@ -1770,8 +1968,8 @@ export function createXStatePlaybookRuntime<TOptions>(
1770
1968
  // DR-029: the last event a public Boss boundary sent into the
1771
1969
  // machine — classified, deterministic entry, or Boss reply — kept with
1772
1970
  // its recorded payload so a failure-state retry action can replay the
1773
- // event that drove the run into `failed`. Process-local: the schema-1
1774
- // 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).
1775
1973
  let lastBossEvent: EventObject | undefined;
1776
1974
  // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1777
1975
  // recorded for each idempotency key, returned verbatim on a repeated
@@ -1793,6 +1991,71 @@ export function createXStatePlaybookRuntime<TOptions>(
1793
1991
  // imperative boundaries await their queued work directly.
1794
1992
  let emissionFailure: unknown;
1795
1993
 
1994
+ function selectPlayerResume(playerId: string): string | false {
1995
+ const selected = session?.playerSessions
1996
+ ? session.playerSessions.select(playerId)
1997
+ : playerResumeTokens.get(playerId) ?? false;
1998
+ if (
1999
+ selected !== false &&
2000
+ (typeof selected !== 'string' || selected.trim().length === 0)
2001
+ ) {
2002
+ throw new TypeError(
2003
+ `player session store returned an invalid resume token for ${playerId}`,
2004
+ );
2005
+ }
2006
+ return selected;
2007
+ }
2008
+
2009
+ function updatePlayerResume(
2010
+ playerId: string,
2011
+ resumeToken: string | undefined,
2012
+ ): void {
2013
+ if (session?.playerSessions) {
2014
+ session.playerSessions.update(playerId, resumeToken);
2015
+ } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
2016
+ playerResumeTokens.set(playerId, resumeToken);
2017
+ } else {
2018
+ playerResumeTokens.delete(playerId);
2019
+ }
2020
+ }
2021
+
2022
+ function snapshotPlayerResumeTokens(): Record<string, string> {
2023
+ const raw = session?.playerSessions
2024
+ ? session.playerSessions.snapshot()
2025
+ : Object.fromEntries(playerResumeTokens);
2026
+ if (!isPlainObject(raw)) {
2027
+ throw new TypeError('player session store snapshot must be an object');
2028
+ }
2029
+ const detached: Record<string, string> = {};
2030
+ for (const [playerId, token] of Object.entries(raw)) {
2031
+ if (playerId.trim().length === 0) {
2032
+ throw new TypeError(
2033
+ 'player session store snapshot player ids must be non-empty',
2034
+ );
2035
+ }
2036
+ if (typeof token !== 'string' || token.trim().length === 0) {
2037
+ throw new TypeError(
2038
+ `player session store snapshot token for ${playerId} must be a non-empty string`,
2039
+ );
2040
+ }
2041
+ detached[playerId] = token;
2042
+ }
2043
+ return detached;
2044
+ }
2045
+
2046
+ function restorePlayerResumeTokens(
2047
+ tokens: Readonly<Record<string, string>>,
2048
+ ): void {
2049
+ if (session?.playerSessions) {
2050
+ session.playerSessions.restore(tokens);
2051
+ return;
2052
+ }
2053
+ playerResumeTokens.clear();
2054
+ for (const [playerId, token] of Object.entries(tokens)) {
2055
+ playerResumeTokens.set(playerId, token);
2056
+ }
2057
+ }
2058
+
1796
2059
  function enqueueEmission(fn: () => Promise<void>): Promise<void> {
1797
2060
  const queued = emissionQueue.add(fn).then(() => undefined);
1798
2061
  activeEmissionCalls.add(queued);
@@ -2001,9 +2264,16 @@ export function createXStatePlaybookRuntime<TOptions>(
2001
2264
  // State-entry telemetry/status must precede the call they describe.
2002
2265
  await drainEmissions();
2003
2266
  const turnId = activeTurnId;
2004
- const callId = `player-${++playerCallSequence}`;
2005
2267
  const stateId = input.stateId;
2006
- const resume = playerResumeTokens.get(playerId) ?? false;
2268
+ let resume: string | false;
2269
+ try {
2270
+ signal.throwIfAborted();
2271
+ resume = selectPlayerResume(playerId);
2272
+ } catch (error) {
2273
+ if (!signal.aborted) controlPlaneError ??= error;
2274
+ throw error;
2275
+ }
2276
+ const callId = `player-${++playerCallSequence}`;
2007
2277
  const identity = {
2008
2278
  purpose: 'captain' as const,
2009
2279
  ...stateIdentity(stateId),
@@ -2095,13 +2365,26 @@ export function createXStatePlaybookRuntime<TOptions>(
2095
2365
  throw error;
2096
2366
  }
2097
2367
 
2098
- if (
2099
- typeof result.resumeToken === 'string' &&
2100
- result.resumeToken.trim().length > 0
2101
- ) {
2102
- playerResumeTokens.set(playerId, result.resumeToken);
2103
- } else {
2104
- playerResumeTokens.delete(playerId);
2368
+ try {
2369
+ updatePlayerResume(
2370
+ playerId,
2371
+ typeof result.resumeToken === 'string' &&
2372
+ result.resumeToken.trim().length > 0
2373
+ ? result.resumeToken
2374
+ : undefined,
2375
+ );
2376
+ } catch (error) {
2377
+ if (!signal.aborted) controlPlaneError ??= error;
2378
+ try {
2379
+ await emitTrace(
2380
+ 'player.call.finished',
2381
+ { ...identity, status: 'error', error: normalizeError(error) },
2382
+ position,
2383
+ );
2384
+ } catch {
2385
+ // The continuation-store failure remains authoritative.
2386
+ }
2387
+ throw error;
2105
2388
  }
2106
2389
 
2107
2390
  await emitTrace(
@@ -3077,16 +3360,45 @@ export function createXStatePlaybookRuntime<TOptions>(
3077
3360
  }
3078
3361
  },
3079
3362
 
3080
- // 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.
3081
3365
  // Defined only at a safe capture point — initialized, not disposing
3082
- // or disposed, no active public boundary, no pending nested call,
3083
- // and the actor quiescent with status `active`.
3366
+ // or disposed, no active public boundary, and the actor quiescent with
3367
+ // status `active`.
3084
3368
  exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
3085
3369
  if (!actor || !session || disposed || disposalPromise !== undefined) {
3086
3370
  return undefined;
3087
3371
  }
3088
3372
  if (activeSignal !== undefined) return undefined;
3089
- 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
+ }
3090
3402
  const state = currentState();
3091
3403
  if (state.status !== 'active' || !state.quiescent) return undefined;
3092
3404
  const machineSnapshot = detachPersistedMachineSnapshot(
@@ -3096,10 +3408,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3096
3408
  .context as Record<string, unknown>;
3097
3409
  const pending = pendingBossQuestionFromContext(context ?? {});
3098
3410
  return {
3099
- schemaVersion: 1,
3411
+ schemaVersion: 2,
3100
3412
  playbookId: session.playbookId,
3101
3413
  machine: machineSnapshot,
3102
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
3414
+ playerResumeTokens: snapshotPlayerResumeTokens(),
3103
3415
  sequences: {
3104
3416
  trace: traceSequence,
3105
3417
  turn: turnSequence,
@@ -3122,6 +3434,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3122
3434
  sourceItem: pending.sourceItem,
3123
3435
  },
3124
3436
  ],
3437
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
3125
3438
  };
3126
3439
  },
3127
3440
 
@@ -3141,7 +3454,16 @@ export function createXStatePlaybookRuntime<TOptions>(
3141
3454
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
3142
3455
  snapshot,
3143
3456
  boundSession.playbookId,
3457
+ { allowSuspendedCall: true },
3144
3458
  );
3459
+ const suspendedCall =
3460
+ boundSnapshot.schemaVersion === 2
3461
+ ? boundSnapshot.suspendedCall
3462
+ : undefined;
3463
+ let priorExternalPlayerTokens:
3464
+ | Readonly<Record<string, string>>
3465
+ | undefined;
3466
+ let externalStoreRestoreAttempted = false;
3145
3467
  initialized = true;
3146
3468
  let finishInitialization!: () => void;
3147
3469
  const initialization = new Promise<void>((resolve) => {
@@ -3163,35 +3485,79 @@ export function createXStatePlaybookRuntime<TOptions>(
3163
3485
  // Every Captain call already consumed at least one trace number,
3164
3486
  // so the global trace counter is a collision-safe id floor.
3165
3487
  boundSnapshot.sequences.trace;
3166
- // The schema-1 snapshot carries no apply counter (PBRT-50: no
3167
- // schema bump); every apply boundary consumed trace numbers, so
3168
- // the persisted trace counter is a collision-safe id floor here
3169
- // 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.
3170
3492
  applyCallSequence = boundSnapshot.sequences.trace;
3171
- playerResumeTokens.clear();
3172
- for (const [playerId, token] of Object.entries(
3173
- boundSnapshot.playerResumeTokens,
3174
- )) {
3175
- playerResumeTokens.set(playerId, token);
3493
+ if (boundSession.playerSessions) {
3494
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
3495
+ externalStoreRestoreAttempted = true;
3496
+ }
3497
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
3498
+ nestedBridge.prepareRestore(suspendedCall);
3499
+ if (suspendedCall !== undefined) {
3500
+ playbookCallTurnIds.set(
3501
+ suspendedCall.callId,
3502
+ suspendedCall.turnId,
3503
+ );
3176
3504
  }
3177
3505
  suppressInspectionEmissions = true;
3178
3506
  actor = buildActor(runtimePorts, boundSnapshot.machine);
3179
3507
  actor.start();
3180
- 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
+ );
3181
3521
  if (restoredState.status !== 'active') {
3182
3522
  throw new Error(
3183
3523
  `createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`,
3184
3524
  );
3185
3525
  }
3186
- 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
+ }
3187
3534
  priorState = restoredState;
3188
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();
3189
3541
  })();
3190
3542
  try {
3191
3543
  await initTask;
3192
3544
  } catch (error) {
3193
- await cleanupFailedStart(error, { emitDisposal: false });
3194
- throw error;
3545
+ let failure = error;
3546
+ if (
3547
+ externalStoreRestoreAttempted &&
3548
+ priorExternalPlayerTokens !== undefined
3549
+ ) {
3550
+ try {
3551
+ boundSession.playerSessions!.restore(priorExternalPlayerTokens);
3552
+ } catch (rollbackError) {
3553
+ failure = new AggregateError(
3554
+ [error, rollbackError],
3555
+ 'createPlaybookRuntime.restore and player continuation rollback failed',
3556
+ );
3557
+ }
3558
+ }
3559
+ await cleanupFailedStart(failure, { emitDisposal: false });
3560
+ throw failure;
3195
3561
  } finally {
3196
3562
  finishInitialization();
3197
3563
  if (initInFlight === initialization) initInFlight = undefined;
@@ -3607,7 +3973,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3607
3973
  } else {
3608
3974
  // 2. Optional Captain-pane classification line: the bare FSM
3609
3975
  // event type, emitted before the FSM advances.
3610
- const statusLine = spec.classificationStatus?.(event);
3976
+ const statusLine = classificationStatus(event);
3611
3977
  if (statusLine !== undefined) {
3612
3978
  await runtimePorts!.emitStatus(statusLine);
3613
3979
  }
@@ -3806,7 +4172,12 @@ export function createXStatePlaybookRuntime<TOptions>(
3806
4172
  }
3807
4173
  }
3808
4174
  } finally {
3809
- playerResumeTokens.clear();
4175
+ // A composing host owns the shared store for the complete root
4176
+ // engagement tree. Child disposal must not erase a token its
4177
+ // caller will resume. The private fallback remains runtime-owned.
4178
+ if (session?.playerSessions === undefined) {
4179
+ playerResumeTokens.clear();
4180
+ }
3810
4181
  activePlayerIds.clear();
3811
4182
  playbookCallTurnIds.clear();
3812
4183
  activeEmissionCalls.clear();
@@ -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;