@sublang/playbook 0.9.0 → 1.3.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 (51) hide show
  1. package/README.md +190 -151
  2. package/package.json +50 -6
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
  12. package/reference/sdlc/code.playbook/bin/run.js +999 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
  21. package/reference/sdlc/code.playbook/code.playbook.js +199 -488
  22. package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
  24. package/reference/sdlc/code.playbook/code.registry.js +0 -3
  25. package/reference/sdlc/code.playbook/code.registry.ts +0 -6
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
  27. package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1165 -89
  42. package/slc/optimize.md +92 -0
  43. package/slc/text2gears.md +255 -7
  44. package/src/runtime.d.ts +146 -3
  45. package/src/runtime.ts +201 -2
  46. package/src/xstate-playbook-runtime.d.ts +201 -0
  47. package/src/xstate-playbook-runtime.js +2058 -0
  48. package/src/xstate-playbook-runtime.ts +2792 -0
  49. package/src/xstate-runtime.d.ts +95 -0
  50. package/src/xstate-runtime.js +1258 -0
  51. package/src/xstate-runtime.ts +1816 -0
@@ -0,0 +1,1816 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import {
5
+ fromPromise,
6
+ waitFor,
7
+ type AnyActorRef,
8
+ type PromiseActorLogic,
9
+ type SnapshotFrom,
10
+ } from 'xstate';
11
+
12
+ import type {
13
+ CaptainResult,
14
+ JsonValue,
15
+ NormalizedError,
16
+ PlaybookCallRequest,
17
+ PlaybookCallResult,
18
+ PlaybookCallStart,
19
+ PlaybookPendingBossQuestion,
20
+ PlaybookPendingCall,
21
+ PlaybookRuntimeSnapshot,
22
+ PlaybookSession,
23
+ PlaybookState,
24
+ PlaybookStateValue,
25
+ PlayerResult,
26
+ } from './runtime.js';
27
+
28
+ // DR-019: the generic linked-runtime factory and its strategy helpers live
29
+ // in the sibling module and are re-exported here so linked artifacts import
30
+ // one shared engine surface.
31
+ export * from './xstate-playbook-runtime.js';
32
+
33
+ const BUSY_TAG = 'playbook.busy';
34
+ const SUSPENDED_TAG = 'playbook.suspended';
35
+
36
+ type Deferred<T> = {
37
+ promise: Promise<T>;
38
+ resolve(value: T): void;
39
+ reject(reason: unknown): void;
40
+ };
41
+
42
+ function deferred<T>(): Deferred<T> {
43
+ let resolve!: (value: T) => void;
44
+ let reject!: (reason: unknown) => void;
45
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
46
+ resolve = resolvePromise;
47
+ reject = rejectPromise;
48
+ });
49
+ return { promise, resolve, reject };
50
+ }
51
+
52
+ function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
53
+ if (signal.aborted) return Promise.reject(signal.reason);
54
+ return new Promise<T>((resolve, reject) => {
55
+ const onAbort = (): void => reject(signal.reason);
56
+ signal.addEventListener('abort', onAbort, { once: true });
57
+ void promise.then(
58
+ (value) => {
59
+ signal.removeEventListener('abort', onAbort);
60
+ resolve(value);
61
+ },
62
+ (error: unknown) => {
63
+ signal.removeEventListener('abort', onAbort);
64
+ reject(error);
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ function isAbortReason(error: unknown, signal: AbortSignal): boolean {
71
+ return (
72
+ signal.aborted &&
73
+ (error === signal.reason || normalizeError(error).name === 'AbortError')
74
+ );
75
+ }
76
+
77
+ const NEVER_ABORTED_SIGNAL = new AbortController().signal;
78
+
79
+ /**
80
+ * Compose invocation-lifetime and imperative-boundary cancellation without
81
+ * installing a second forwarding listener in each generated runtime.
82
+ */
83
+ export function combineAbortSignals(
84
+ ...signals: readonly (AbortSignal | undefined)[]
85
+ ): AbortSignal {
86
+ const present: AbortSignal[] = [];
87
+ for (const [index, signal] of signals.entries()) {
88
+ if (signal === undefined) continue;
89
+ if (!(signal instanceof AbortSignal)) {
90
+ throw new TypeError(`abort signal ${index} must be an AbortSignal`);
91
+ }
92
+ present.push(signal);
93
+ }
94
+ if (present.length === 0) return NEVER_ABORTED_SIGNAL;
95
+ if (present.length === 1) return present[0];
96
+ return AbortSignal.any(present);
97
+ }
98
+
99
+ const abortCleanups = new WeakMap<AbortSignal, Set<Promise<unknown>>>();
100
+
101
+ /**
102
+ * Register host cleanup started synchronously by an invocation abort.
103
+ * The nested bridge drains these promises before it publishes the matching
104
+ * call-finish boundary, without widening the public six-port contract.
105
+ */
106
+ export function registerPlaybookAbortCleanup(
107
+ signal: AbortSignal,
108
+ cleanup: Promise<unknown>,
109
+ ): void {
110
+ let pending = abortCleanups.get(signal);
111
+ if (!pending) {
112
+ pending = new Set();
113
+ abortCleanups.set(signal, pending);
114
+ }
115
+ pending.add(cleanup);
116
+ // Mark rejection handled immediately, but retain the settled promise until
117
+ // the bridge's allSettled drain observes its outcome.
118
+ void cleanup.catch(() => undefined);
119
+ }
120
+
121
+ async function drainPlaybookAbortCleanups(signal: AbortSignal): Promise<void> {
122
+ const failures: unknown[] = [];
123
+ while (true) {
124
+ const pending = abortCleanups.get(signal);
125
+ if (!pending || pending.size === 0) break;
126
+ const batch = [...pending];
127
+ pending.clear();
128
+ const outcomes = await Promise.allSettled(batch);
129
+ for (const outcome of outcomes) {
130
+ if (outcome.status === 'rejected') failures.push(outcome.reason);
131
+ }
132
+ }
133
+ abortCleanups.delete(signal);
134
+ if (failures.length === 1) throw failures[0];
135
+ if (failures.length > 1) {
136
+ throw new AggregateError(failures, 'playbook abort cleanup failed');
137
+ }
138
+ }
139
+
140
+ function isRecord(value: unknown): value is Record<string, unknown> {
141
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
142
+ return false;
143
+ }
144
+ const prototype = Object.getPrototypeOf(value) as unknown;
145
+ return prototype === Object.prototype || prototype === null;
146
+ }
147
+
148
+ function own(value: object, key: PropertyKey): boolean {
149
+ return Object.prototype.hasOwnProperty.call(value, key);
150
+ }
151
+
152
+ /**
153
+ * Validate and detach one JSON value from the exact property descriptors that
154
+ * were inspected. Reading `value[key]` after validation would let a Proxy
155
+ * substitute a different value between the check and the clone.
156
+ */
157
+ function snapshotJsonValueFromDescriptors(
158
+ value: unknown,
159
+ path = '$',
160
+ ancestors: ReadonlySet<object> = new Set(),
161
+ ): JsonValue {
162
+ if (
163
+ value === null ||
164
+ typeof value === 'string' ||
165
+ typeof value === 'boolean'
166
+ ) {
167
+ return value;
168
+ }
169
+ if (typeof value === 'number') {
170
+ if (!Number.isFinite(value)) {
171
+ throw new TypeError(`${path} must contain a finite JSON number`);
172
+ }
173
+ if (Object.is(value, -0)) {
174
+ throw new TypeError(`${path} must not contain negative zero`);
175
+ }
176
+ return value;
177
+ }
178
+ if (Array.isArray(value)) {
179
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
180
+ throw new TypeError(`${path} must be a plain JSON array`);
181
+ }
182
+ if (ancestors.has(value)) {
183
+ throw new TypeError(`${path} must not contain a JSON cycle`);
184
+ }
185
+ const nextAncestors = new Set(ancestors).add(value);
186
+ const descriptors = Object.getOwnPropertyDescriptors(value);
187
+ const descriptorMap = descriptors as unknown as Record<
188
+ PropertyKey,
189
+ PropertyDescriptor | undefined
190
+ >;
191
+ const descriptorKeys = Reflect.ownKeys(descriptors);
192
+ if (descriptorKeys.some((key) => typeof key === 'symbol')) {
193
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
194
+ }
195
+ const lengthDescriptor = descriptorMap.length;
196
+ if (
197
+ !lengthDescriptor ||
198
+ !own(lengthDescriptor, 'value') ||
199
+ !Number.isSafeInteger(lengthDescriptor.value) ||
200
+ lengthDescriptor.value < 0
201
+ ) {
202
+ throw new TypeError(`${path} must be a plain JSON array`);
203
+ }
204
+ const length = lengthDescriptor.value as number;
205
+ const indexed: Array<[number, PropertyDescriptor]> = [];
206
+ for (const key of descriptorKeys) {
207
+ if (typeof key === 'symbol') continue;
208
+ if (key === 'length') continue;
209
+ const descriptor = descriptorMap[key];
210
+ if (!descriptor) continue;
211
+ const index = Number(key);
212
+ if (
213
+ !Number.isSafeInteger(index) ||
214
+ index < 0 ||
215
+ index >= length ||
216
+ String(index) !== key
217
+ ) {
218
+ throw new TypeError(`${path}.${key} is not a JSON array index`);
219
+ }
220
+ indexed.push([index, descriptor]);
221
+ }
222
+ if (indexed.length !== length) {
223
+ throw new TypeError(`${path} must not be a sparse JSON array`);
224
+ }
225
+ indexed.sort(([left], [right]) => left - right);
226
+ const copy: JsonValue[] = [];
227
+ for (const [index, descriptor] of indexed) {
228
+ if (!descriptor.enumerable) {
229
+ throw new TypeError(
230
+ `${path}[${index}] must be an enumerable JSON property`,
231
+ );
232
+ }
233
+ if (!own(descriptor, 'value')) {
234
+ throw new TypeError(`${path}[${index}] must be a JSON data property`);
235
+ }
236
+ copy.push(
237
+ snapshotJsonValueFromDescriptors(
238
+ descriptor.value,
239
+ `${path}[${index}]`,
240
+ nextAncestors,
241
+ ),
242
+ );
243
+ }
244
+ return Object.freeze(copy);
245
+ }
246
+ if (!isRecord(value)) {
247
+ throw new TypeError(`${path} must be a JSON value`);
248
+ }
249
+ if (ancestors.has(value)) {
250
+ throw new TypeError(`${path} must not contain a JSON cycle`);
251
+ }
252
+ const descriptors = Object.getOwnPropertyDescriptors(value);
253
+ const descriptorKeys = Reflect.ownKeys(descriptors);
254
+ if (descriptorKeys.some((key) => typeof key === 'symbol')) {
255
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
256
+ }
257
+ const nextAncestors = new Set(ancestors).add(value);
258
+ const copy: Record<string, JsonValue> = {};
259
+ for (const key of descriptorKeys) {
260
+ if (typeof key === 'symbol') continue;
261
+ const descriptor = descriptors[key];
262
+ if (!descriptor) continue;
263
+ if (!descriptor.enumerable) {
264
+ throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
265
+ }
266
+ if (!own(descriptor, 'value')) {
267
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
268
+ }
269
+ defineEnumerableDataProperty(
270
+ copy,
271
+ key,
272
+ snapshotJsonValueFromDescriptors(
273
+ descriptor.value,
274
+ `${path}.${key}`,
275
+ nextAncestors,
276
+ ),
277
+ );
278
+ }
279
+ return Object.freeze(copy);
280
+ }
281
+
282
+ /** Reject values that would be changed, omitted, or rejected by JSON. */
283
+ export function assertJsonSafe(
284
+ value: unknown,
285
+ path = '$',
286
+ ancestors: ReadonlySet<object> = new Set(),
287
+ ): asserts value is JsonValue {
288
+ snapshotJsonValueFromDescriptors(value, path, ancestors);
289
+ }
290
+
291
+ function defineEnumerableDataProperty<T>(
292
+ target: Record<string, T>,
293
+ key: string,
294
+ value: T,
295
+ ): void {
296
+ // Assignment to Object.prototype's legacy `__proto__` setter changes a
297
+ // clone's prototype and silently drops the JSON member. Defining an own data
298
+ // property preserves every valid JSON key while retaining an ordinary object
299
+ // prototype for callers.
300
+ Object.defineProperty(target, key, {
301
+ value,
302
+ enumerable: true,
303
+ configurable: true,
304
+ writable: true,
305
+ });
306
+ }
307
+
308
+ /** Validate, detach, and recursively freeze host-owned JSON input. */
309
+ export function snapshotJsonValue(value: unknown, path = '$'): JsonValue {
310
+ return snapshotJsonValueFromDescriptors(value, path);
311
+ }
312
+
313
+ function capturedDataValue(
314
+ descriptors: PropertyDescriptorMap,
315
+ key: string,
316
+ path: string,
317
+ required = true,
318
+ ): unknown {
319
+ const descriptor = descriptors[key];
320
+ if (!descriptor) {
321
+ if (required) throw new TypeError(`${path} must be an own data property`);
322
+ return undefined;
323
+ }
324
+ if (!own(descriptor, 'value')) {
325
+ throw new TypeError(`${path} must be an own data property`);
326
+ }
327
+ return descriptor.value;
328
+ }
329
+
330
+ function capturedPort<K extends keyof PlaybookSession['ports']>(
331
+ descriptors: PropertyDescriptorMap,
332
+ name: K,
333
+ ): PlaybookSession['ports'][K] {
334
+ const value = capturedDataValue(
335
+ descriptors,
336
+ name,
337
+ `playbook session ports.${name}`,
338
+ );
339
+ if (typeof value !== 'function') {
340
+ throw new TypeError(`playbook session ports.${name} must be a function`);
341
+ }
342
+ return value as PlaybookSession['ports'][K];
343
+ }
344
+
345
+ /** Validate session causality and detach its immutable identity from the host. */
346
+ export function snapshotPlaybookSession(
347
+ session: PlaybookSession,
348
+ ): PlaybookSession {
349
+ if (!isRecord(session)) {
350
+ throw new TypeError('playbook session must be an object');
351
+ }
352
+ const sessionDescriptors = Object.getOwnPropertyDescriptors(session);
353
+ const sessionId = requireNonEmptyString(
354
+ capturedDataValue(
355
+ sessionDescriptors,
356
+ 'sessionId',
357
+ 'playbook session sessionId',
358
+ ),
359
+ 'playbook session sessionId',
360
+ );
361
+ const playbookId = requireNonEmptyString(
362
+ capturedDataValue(
363
+ sessionDescriptors,
364
+ 'playbookId',
365
+ 'playbook session playbookId',
366
+ ),
367
+ 'playbook session playbookId',
368
+ );
369
+ const rootSessionId = requireNonEmptyString(
370
+ capturedDataValue(
371
+ sessionDescriptors,
372
+ 'rootSessionId',
373
+ 'playbook session rootSessionId',
374
+ ),
375
+ 'playbook session rootSessionId',
376
+ );
377
+ const capturedDepth = capturedDataValue(
378
+ sessionDescriptors,
379
+ 'depth',
380
+ 'playbook session depth',
381
+ );
382
+ if (!Number.isSafeInteger(capturedDepth) || (capturedDepth as number) < 0) {
383
+ throw new TypeError(
384
+ 'playbook session depth must be a non-negative integer',
385
+ );
386
+ }
387
+ const depth = capturedDepth as number;
388
+ const capturedParentSessionId = capturedDataValue(
389
+ sessionDescriptors,
390
+ 'parentSessionId',
391
+ 'playbook session parentSessionId',
392
+ false,
393
+ );
394
+ const capturedParentCallId = capturedDataValue(
395
+ sessionDescriptors,
396
+ 'parentCallId',
397
+ 'playbook session parentCallId',
398
+ false,
399
+ );
400
+ const hasParentSessionId = Object.prototype.hasOwnProperty.call(
401
+ sessionDescriptors,
402
+ 'parentSessionId',
403
+ );
404
+ const hasParentCallId = Object.prototype.hasOwnProperty.call(
405
+ sessionDescriptors,
406
+ 'parentCallId',
407
+ );
408
+ let parentSessionId: string | undefined;
409
+ let parentCallId: string | undefined;
410
+ if (depth === 0) {
411
+ if (rootSessionId !== sessionId) {
412
+ throw new TypeError(
413
+ 'root playbook session must be its own rootSessionId',
414
+ );
415
+ }
416
+ if (hasParentSessionId || hasParentCallId) {
417
+ throw new TypeError(
418
+ 'root playbook session must not carry parent identity',
419
+ );
420
+ }
421
+ } else {
422
+ parentSessionId = requireNonEmptyString(
423
+ capturedParentSessionId,
424
+ 'playbook session parentSessionId',
425
+ );
426
+ parentCallId = requireNonEmptyString(
427
+ capturedParentCallId,
428
+ 'playbook session parentCallId',
429
+ );
430
+ if (sessionId === rootSessionId || sessionId === parentSessionId) {
431
+ throw new TypeError(
432
+ 'child playbook sessionId must differ from its root and parent session ids',
433
+ );
434
+ }
435
+ }
436
+ const capturedPorts = capturedDataValue(
437
+ sessionDescriptors,
438
+ 'ports',
439
+ 'playbook session ports',
440
+ );
441
+ if (!isRecord(capturedPorts)) {
442
+ throw new TypeError('playbook session ports must be an object');
443
+ }
444
+ const portDescriptors = Object.getOwnPropertyDescriptors(capturedPorts);
445
+ const ports: PlaybookSession['ports'] = Object.freeze({
446
+ callPlayer: capturedPort(portDescriptors, 'callPlayer'),
447
+ callCaptain: capturedPort(portDescriptors, 'callCaptain'),
448
+ callJudge: capturedPort(portDescriptors, 'callJudge'),
449
+ callPlaybook: capturedPort(portDescriptors, 'callPlaybook'),
450
+ emitStatus: capturedPort(portDescriptors, 'emitStatus'),
451
+ emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
452
+ });
453
+ return Object.freeze({
454
+ sessionId,
455
+ playbookId,
456
+ rootSessionId,
457
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
458
+ ...(parentCallId === undefined ? {} : { parentCallId }),
459
+ depth,
460
+ ports,
461
+ });
462
+ }
463
+
464
+ export function normalizeError(error: unknown): NormalizedError {
465
+ if (error instanceof Error) {
466
+ let name = 'Error';
467
+ let message = 'Unknown error';
468
+ let stack: string | undefined;
469
+ try {
470
+ if (typeof error.name === 'string' && error.name.length > 0) {
471
+ name = error.name;
472
+ }
473
+ } catch {
474
+ // Keep the stable fallback for hostile Error subclasses.
475
+ }
476
+ try {
477
+ if (typeof error.message === 'string') message = error.message;
478
+ } catch {
479
+ // Keep the stable fallback for hostile Error subclasses.
480
+ }
481
+ try {
482
+ if (typeof error.stack === 'string') stack = error.stack;
483
+ } catch {
484
+ // A stack is optional at the public boundary.
485
+ }
486
+ return {
487
+ name,
488
+ message,
489
+ ...(stack ? { stack } : {}),
490
+ };
491
+ }
492
+ if (typeof error === 'string') {
493
+ return { name: 'Error', message: error };
494
+ }
495
+ try {
496
+ assertJsonSafe(error);
497
+ if (isRecord(error) && typeof error.message === 'string') {
498
+ return {
499
+ name:
500
+ typeof error.name === 'string' && error.name.length > 0
501
+ ? error.name
502
+ : 'Error',
503
+ message: error.message,
504
+ ...(typeof error.stack === 'string' ? { stack: error.stack } : {}),
505
+ };
506
+ }
507
+ return { name: 'Error', message: JSON.stringify(error) };
508
+ } catch {
509
+ try {
510
+ return { name: 'Error', message: String(error) };
511
+ } catch {
512
+ return { name: 'Error', message: 'Unknown error' };
513
+ }
514
+ }
515
+ }
516
+
517
+ function requireNonEmptyString(value: unknown, path: string): string {
518
+ if (typeof value !== 'string' || value.trim().length === 0) {
519
+ throw new TypeError(`${path} must be a non-empty string`);
520
+ }
521
+ return value;
522
+ }
523
+
524
+ function normalizeStateValue(
525
+ value: unknown,
526
+ path = 'snapshot.value',
527
+ ancestors: ReadonlySet<object> = new Set(),
528
+ ): PlaybookStateValue {
529
+ if (typeof value === 'string') return value;
530
+ if (!isRecord(value)) {
531
+ throw new TypeError(`${path} must be an XState string or object value`);
532
+ }
533
+ if (ancestors.has(value)) {
534
+ throw new TypeError(`${path} must not contain an XState state cycle`);
535
+ }
536
+ const nextAncestors = new Set(ancestors).add(value);
537
+ const normalized: Record<string, PlaybookStateValue> = {};
538
+ for (const key of Object.keys(value).sort()) {
539
+ defineEnumerableDataProperty(
540
+ normalized,
541
+ key,
542
+ normalizeStateValue(value[key], `${path}.${key}`, nextAncestors),
543
+ );
544
+ }
545
+ return normalized;
546
+ }
547
+
548
+ export interface PlaybookStateMetadata {
549
+ stateId: string;
550
+ description: string;
551
+ }
552
+
553
+ interface MachineSnapshotLike {
554
+ value: unknown;
555
+ status: 'active' | 'done' | 'error' | 'stopped';
556
+ tags: ReadonlySet<string>;
557
+ getMeta(): Record<string, unknown>;
558
+ }
559
+
560
+ function asMachineSnapshot(snapshot: unknown): MachineSnapshotLike {
561
+ if (!isRecord(snapshot)) {
562
+ throw new TypeError('snapshot must be an XState machine snapshot');
563
+ }
564
+ const status = snapshot.status;
565
+ if (
566
+ status !== 'active' &&
567
+ status !== 'done' &&
568
+ status !== 'error' &&
569
+ status !== 'stopped'
570
+ ) {
571
+ throw new TypeError('snapshot.status is not an XState actor status');
572
+ }
573
+ if (!(snapshot.tags instanceof Set)) {
574
+ throw new TypeError('snapshot.tags must be an XState tag set');
575
+ }
576
+ if (typeof snapshot.getMeta !== 'function') {
577
+ throw new TypeError('snapshot.getMeta must be an XState public method');
578
+ }
579
+ return snapshot as unknown as MachineSnapshotLike;
580
+ }
581
+
582
+ /** Read stable state identity without consulting XState's private `_nodes`. */
583
+ export function activePlaybookStateMetadata(
584
+ snapshot: unknown,
585
+ ): readonly PlaybookStateMetadata[] {
586
+ const machineSnapshot = asMachineSnapshot(snapshot);
587
+ const byStateId = new Map<string, PlaybookStateMetadata>();
588
+ for (const [nodeId, meta] of Object.entries(machineSnapshot.getMeta())) {
589
+ if (!isRecord(meta) || !own(meta, 'playbook')) continue;
590
+ if (!isRecord(meta.playbook)) {
591
+ throw new TypeError(`${nodeId}.meta.playbook must be an object`);
592
+ }
593
+ const stateId = requireNonEmptyString(
594
+ meta.playbook.stateId,
595
+ `${nodeId}.meta.playbook.stateId`,
596
+ );
597
+ const description = requireNonEmptyString(
598
+ meta.playbook.description,
599
+ `${nodeId}.meta.playbook.description`,
600
+ );
601
+ const previous = byStateId.get(stateId);
602
+ if (previous && previous.description !== description) {
603
+ throw new TypeError(
604
+ `active state id ${stateId} has conflicting descriptions`,
605
+ );
606
+ }
607
+ byStateId.set(stateId, { stateId, description });
608
+ }
609
+ return [...byStateId.values()].sort((left, right) =>
610
+ left.stateId.localeCompare(right.stateId),
611
+ );
612
+ }
613
+
614
+ export interface SnapshotNormalizationOptions {
615
+ pendingCall?: PlaybookPendingCall;
616
+ }
617
+
618
+ export function normalizePlaybookSnapshot(
619
+ snapshot: unknown,
620
+ options: SnapshotNormalizationOptions = {},
621
+ ): PlaybookState {
622
+ const machineSnapshot = asMachineSnapshot(snapshot);
623
+ const active = activePlaybookStateMetadata(machineSnapshot);
624
+ const activeStateIds = active.map(({ stateId }) => stateId);
625
+ const tags = [...machineSnapshot.tags].sort();
626
+ const busy = tags.includes(BUSY_TAG);
627
+ const suspended = tags.includes(SUSPENDED_TAG);
628
+ const quiescent =
629
+ machineSnapshot.status !== 'active' ||
630
+ (!busy && (!suspended || options.pendingCall !== undefined));
631
+ return {
632
+ value: normalizeStateValue(machineSnapshot.value),
633
+ activeStateIds,
634
+ tags,
635
+ status: machineSnapshot.status,
636
+ quiescent,
637
+ ...(activeStateIds.length === 1 ? { stateId: activeStateIds[0] } : {}),
638
+ };
639
+ }
640
+
641
+ // DR-014 §1: deep-detach an XState persisted actor snapshot into strict
642
+ // JSON for a PlaybookRuntimeSnapshot, normalizing any raw Error value
643
+ // (for example FSM context `lastError`) instead of rejecting it.
644
+ export function detachPersistedMachineSnapshot(persisted: unknown): JsonValue {
645
+ return snapshotJsonValue(
646
+ withErrorsNormalized(persisted, new Set()),
647
+ 'persisted machine snapshot',
648
+ );
649
+ }
650
+
651
+ function withErrorsNormalized(
652
+ value: unknown,
653
+ ancestors: ReadonlySet<object>,
654
+ ): unknown {
655
+ if (value instanceof Error) return normalizeError(value);
656
+ if (Array.isArray(value)) {
657
+ if (ancestors.has(value)) return value;
658
+ const nextAncestors = new Set(ancestors).add(value);
659
+ return value.map((entry) => withErrorsNormalized(entry, nextAncestors));
660
+ }
661
+ if (isRecord(value)) {
662
+ if (ancestors.has(value)) return value;
663
+ const nextAncestors = new Set(ancestors).add(value);
664
+ const normalized: Record<string, unknown> = {};
665
+ for (const key of Object.keys(value)) {
666
+ // XState persisted snapshots carry `output: undefined` (and similar)
667
+ // on non-final states; JSON serialization drops those members, so the
668
+ // detached snapshot drops them too instead of rejecting.
669
+ if (value[key] === undefined) continue;
670
+ defineEnumerableDataProperty(
671
+ normalized,
672
+ key,
673
+ withErrorsNormalized(value[key], nextAncestors),
674
+ );
675
+ }
676
+ return normalized;
677
+ }
678
+ return value;
679
+ }
680
+
681
+ const SNAPSHOT_SEQUENCE_KEYS = [
682
+ 'trace',
683
+ 'turn',
684
+ 'judgeCall',
685
+ 'playerCall',
686
+ 'playbookCall',
687
+ ] as const;
688
+
689
+ // DR-014 §1: validate and detach a host-supplied runtime snapshot before
690
+ // restore touches any state. Rejects a schema-version or playbook-id
691
+ // mismatch with a path-named error.
692
+ export function assertPlaybookRuntimeSnapshot(
693
+ value: unknown,
694
+ expectedPlaybookId: string,
695
+ ): PlaybookRuntimeSnapshot {
696
+ if (!isRecord(value)) {
697
+ throw new TypeError('runtime snapshot must be an object');
698
+ }
699
+ if (value.schemaVersion !== 1) {
700
+ throw new TypeError(
701
+ `runtime snapshot schemaVersion ${String(value.schemaVersion)} is not supported (expected 1)`,
702
+ );
703
+ }
704
+ const playbookId = requireNonEmptyString(
705
+ value.playbookId,
706
+ 'runtime snapshot playbookId',
707
+ );
708
+ if (playbookId !== expectedPlaybookId) {
709
+ throw new TypeError(
710
+ `runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`,
711
+ );
712
+ }
713
+ if (!isRecord(value.machine)) {
714
+ throw new TypeError('runtime snapshot machine must be an object');
715
+ }
716
+ const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
717
+ if (!isRecord(value.playerResumeTokens)) {
718
+ throw new TypeError(
719
+ 'runtime snapshot playerResumeTokens must be an object',
720
+ );
721
+ }
722
+ const playerResumeTokens: Record<string, string> = {};
723
+ for (const [playerId, token] of Object.entries(value.playerResumeTokens)) {
724
+ defineEnumerableDataProperty(
725
+ playerResumeTokens,
726
+ playerId,
727
+ requireNonEmptyString(
728
+ token,
729
+ `runtime snapshot playerResumeTokens.${playerId}`,
730
+ ),
731
+ );
732
+ }
733
+ if (!isRecord(value.sequences)) {
734
+ throw new TypeError('runtime snapshot sequences must be an object');
735
+ }
736
+ const sequences = {} as PlaybookRuntimeSnapshot['sequences'];
737
+ for (const key of SNAPSHOT_SEQUENCE_KEYS) {
738
+ const sequence = value.sequences[key];
739
+ if (!Number.isSafeInteger(sequence) || (sequence as number) < 0) {
740
+ throw new TypeError(
741
+ `runtime snapshot sequences.${key} must be a non-negative integer`,
742
+ );
743
+ }
744
+ sequences[key] = sequence as number;
745
+ }
746
+ const captainCall = value.sequences.captainCall;
747
+ if (captainCall !== undefined) {
748
+ if (!Number.isSafeInteger(captainCall) || (captainCall as number) < 0) {
749
+ throw new TypeError(
750
+ 'runtime snapshot sequences.captainCall must be a non-negative integer',
751
+ );
752
+ }
753
+ sequences.captainCall = captainCall as number;
754
+ }
755
+ validateState(value.state, 'runtime snapshot state');
756
+ const state = snapshotJsonValue(
757
+ value.state,
758
+ 'runtime snapshot state',
759
+ ) as unknown as PlaybookState;
760
+ if (!Array.isArray(value.pendingBossQuestions)) {
761
+ throw new TypeError(
762
+ 'runtime snapshot pendingBossQuestions must be an array',
763
+ );
764
+ }
765
+ const pendingBossQuestions = value.pendingBossQuestions.map(
766
+ (entry, index) => {
767
+ const path = `runtime snapshot pendingBossQuestions[${index}]`;
768
+ if (!isRecord(entry)) throw new TypeError(`${path} must be an object`);
769
+ const question: PlaybookPendingBossQuestion = {
770
+ questionId: requireNonEmptyString(
771
+ entry.questionId,
772
+ `${path}.questionId`,
773
+ ),
774
+ player: requireNonEmptyString(entry.player, `${path}.player`),
775
+ question: requireNonEmptyString(entry.question, `${path}.question`),
776
+ ...(entry.sourceItem === undefined
777
+ ? {}
778
+ : {
779
+ sourceItem: requireNonEmptyString(
780
+ entry.sourceItem,
781
+ `${path}.sourceItem`,
782
+ ),
783
+ }),
784
+ };
785
+ return Object.freeze(question);
786
+ },
787
+ );
788
+ return Object.freeze({
789
+ schemaVersion: 1,
790
+ playbookId,
791
+ machine,
792
+ playerResumeTokens: Object.freeze(playerResumeTokens),
793
+ sequences: Object.freeze(sequences),
794
+ state,
795
+ pendingBossQuestions: Object.freeze(pendingBossQuestions),
796
+ });
797
+ }
798
+
799
+ export interface NestedPlaybookInput {
800
+ stateId: string;
801
+ playbookId: string;
802
+ text: string;
803
+ }
804
+
805
+ export interface PlaybookCallStarted {
806
+ callId: string;
807
+ stateId: string;
808
+ playbookId: string;
809
+ text: string;
810
+ }
811
+
812
+ export interface PlaybookCallFinished extends PlaybookCallStarted {
813
+ result: PlaybookCallResult;
814
+ }
815
+
816
+ export interface NestedPlaybookBridgeOptions {
817
+ nextCallId(): string;
818
+ /** Active public runtime boundary whose abort also owns a new child call. */
819
+ getBoundarySignal?(): AbortSignal | undefined;
820
+ callPlaybook(
821
+ request: PlaybookCallRequest,
822
+ signal: AbortSignal,
823
+ ): Promise<PlaybookCallStart>;
824
+ emitStarted(event: PlaybookCallStarted): Promise<void>;
825
+ emitFinished(event: PlaybookCallFinished): Promise<void>;
826
+ drain(): Promise<void>;
827
+ bindResumeSignal?(signal: AbortSignal): void;
828
+ onControlPlaneError?(error: unknown): void;
829
+ onBackgroundError?(error: unknown): void;
830
+ }
831
+
832
+ export class NestedPlaybookCallError extends Error {
833
+ readonly result: PlaybookCallResult;
834
+
835
+ constructor(result: PlaybookCallResult) {
836
+ const fallback = `Child playbook ${result.playbookId} ${result.status}`;
837
+ const normalized = result.status === 'ok' ? undefined : result.error;
838
+ super(normalized?.message ?? fallback);
839
+ this.name = normalized?.name ?? 'NestedPlaybookCallError';
840
+ if (normalized?.stack) this.stack = normalized.stack;
841
+ this.result = result;
842
+ }
843
+ }
844
+
845
+ interface ActiveCall {
846
+ readonly callId: string;
847
+ readonly input: NestedPlaybookInput;
848
+ readonly deferred: Deferred<JsonValue | undefined>;
849
+ readonly finished: Deferred<void>;
850
+ readonly controller: AbortController;
851
+ readonly signal: AbortSignal;
852
+ phase: 'starting' | 'suspended' | 'settling';
853
+ childSessionId?: string;
854
+ abortListener?: () => void;
855
+ settlement?: Promise<void>;
856
+ runError?: unknown;
857
+ }
858
+
859
+ export interface PendingCallObserver {
860
+ getPendingCall(): PlaybookPendingCall | undefined;
861
+ subscribePendingCall(
862
+ listener: (pendingCall: PlaybookPendingCall) => void,
863
+ ): () => void;
864
+ }
865
+
866
+ export interface NestedPlaybookBridge<
867
+ TInput extends NestedPlaybookInput = NestedPlaybookInput,
868
+ > extends PendingCallObserver {
869
+ actorLogic: PromiseActorLogic<JsonValue | undefined, TInput>;
870
+ resume(input: {
871
+ callId: string;
872
+ result: PlaybookCallResult;
873
+ signal: AbortSignal;
874
+ }): Promise<void>;
875
+ abortPending(error?: unknown): Promise<void>;
876
+ dispose(): Promise<void>;
877
+ }
878
+
879
+ function validateState(state: unknown, path: string): void {
880
+ if (!isRecord(state)) throw new TypeError(`${path} must be an object`);
881
+ rejectUnknownKeys(
882
+ state,
883
+ ['value', 'activeStateIds', 'tags', 'status', 'quiescent', 'stateId'],
884
+ path,
885
+ );
886
+ normalizeStateValue(state.value, `${path}.value`);
887
+ if (!Array.isArray(state.activeStateIds)) {
888
+ throw new TypeError(`${path}.activeStateIds must be an array`);
889
+ }
890
+ state.activeStateIds.forEach((value, index) => {
891
+ requireNonEmptyString(value, `${path}.activeStateIds[${index}]`);
892
+ });
893
+ if (new Set(state.activeStateIds).size !== state.activeStateIds.length) {
894
+ throw new TypeError(`${path}.activeStateIds must not contain duplicates`);
895
+ }
896
+ if (
897
+ !Array.isArray(state.tags) ||
898
+ !state.tags.every((tag) => typeof tag === 'string' && tag.trim().length > 0)
899
+ ) {
900
+ throw new TypeError(`${path}.tags must be a non-empty string array`);
901
+ }
902
+ if (new Set(state.tags).size !== state.tags.length) {
903
+ throw new TypeError(`${path}.tags must not contain duplicates`);
904
+ }
905
+ if (
906
+ state.status !== 'active' &&
907
+ state.status !== 'done' &&
908
+ state.status !== 'error' &&
909
+ state.status !== 'stopped'
910
+ ) {
911
+ throw new TypeError(`${path}.status is invalid`);
912
+ }
913
+ if (typeof state.quiescent !== 'boolean') {
914
+ throw new TypeError(`${path}.quiescent must be boolean`);
915
+ }
916
+ if (own(state, 'stateId')) {
917
+ const stateId = requireNonEmptyString(state.stateId, `${path}.stateId`);
918
+ if (
919
+ state.activeStateIds.length !== 1 ||
920
+ state.activeStateIds[0] !== stateId
921
+ ) {
922
+ throw new TypeError(
923
+ `${path}.stateId must equal the sole active state id`,
924
+ );
925
+ }
926
+ }
927
+ }
928
+
929
+ function rejectUnknownKeys(
930
+ value: Record<string, unknown>,
931
+ allowed: readonly string[],
932
+ path: string,
933
+ ): void {
934
+ const allowedKeys = new Set(allowed);
935
+ for (const key of Object.keys(value)) {
936
+ if (!allowedKeys.has(key)) {
937
+ throw new TypeError(`${path}.${key} is not a declared property`);
938
+ }
939
+ }
940
+ }
941
+
942
+ function validateRunStatus(
943
+ status: unknown,
944
+ path: string,
945
+ ): asserts status is 'ok' | 'aborted' | 'error' {
946
+ if (status !== 'ok' && status !== 'aborted' && status !== 'error') {
947
+ throw new TypeError(`${path} is invalid`);
948
+ }
949
+ }
950
+
951
+ function validateOptionalString(
952
+ value: Record<string, unknown>,
953
+ key: string,
954
+ path: string,
955
+ ): void {
956
+ if (own(value, key) && typeof value[key] !== 'string') {
957
+ throw new TypeError(`${path}.${key} must be a string`);
958
+ }
959
+ }
960
+
961
+ /** Validate, detach, and freeze a host direct-Captain result. */
962
+ export function validateCaptainResult(
963
+ value: unknown,
964
+ path = 'Captain result',
965
+ ): CaptainResult {
966
+ const result = snapshotJsonValue(value, path);
967
+ if (!isRecord(result)) {
968
+ throw new TypeError(`${path} must be an object`);
969
+ }
970
+ rejectUnknownKeys(result, ['status', 'finalText', 'error'], path);
971
+ validateRunStatus(result.status, `${path}.status`);
972
+ validateOptionalString(result, 'finalText', path);
973
+ validateOptionalString(result, 'error', path);
974
+ return result as unknown as CaptainResult;
975
+ }
976
+
977
+ /** Validate, detach, and freeze a host delegated-player result. */
978
+ export function validatePlayerResult(
979
+ value: unknown,
980
+ path = 'player result',
981
+ ): PlayerResult {
982
+ const result = snapshotJsonValue(value, path);
983
+ if (!isRecord(result)) {
984
+ throw new TypeError(`${path} must be an object`);
985
+ }
986
+ rejectUnknownKeys(
987
+ result,
988
+ ['status', 'resumeToken', 'finalText', 'error'],
989
+ path,
990
+ );
991
+ validateRunStatus(result.status, `${path}.status`);
992
+ validateOptionalString(result, 'resumeToken', path);
993
+ validateOptionalString(result, 'finalText', path);
994
+ validateOptionalString(result, 'error', path);
995
+ return result as unknown as PlayerResult;
996
+ }
997
+
998
+ function validateNormalizedError(error: unknown, path: string): void {
999
+ if (!isRecord(error)) {
1000
+ throw new TypeError(`${path} must be a normalized error`);
1001
+ }
1002
+ rejectUnknownKeys(error, ['name', 'message', 'stack'], path);
1003
+ requireNonEmptyString(error.name, `${path}.name`);
1004
+ if (typeof error.message !== 'string') {
1005
+ throw new TypeError(`${path}.message must be a string`);
1006
+ }
1007
+ if (error.stack !== undefined && typeof error.stack !== 'string') {
1008
+ throw new TypeError(`${path}.stack must be a string`);
1009
+ }
1010
+ }
1011
+
1012
+ export function validatePlaybookCallResult(
1013
+ result: unknown,
1014
+ expectedPlaybookId: string,
1015
+ expectedChildSessionId?: string,
1016
+ ): PlaybookCallResult {
1017
+ const capturedResult = snapshotJsonValue(result, 'playbook result');
1018
+ if (!isRecord(capturedResult)) {
1019
+ throw new TypeError('playbook result must be an object');
1020
+ }
1021
+ if (
1022
+ capturedResult.status !== 'ok' &&
1023
+ capturedResult.status !== 'aborted' &&
1024
+ capturedResult.status !== 'error'
1025
+ ) {
1026
+ throw new TypeError('playbook result status is invalid');
1027
+ }
1028
+ if (capturedResult.playbookId !== expectedPlaybookId) {
1029
+ throw new PlaybookCallIdentityError(
1030
+ `playbook result target ${String(capturedResult.playbookId)} does not match ${expectedPlaybookId}`,
1031
+ );
1032
+ }
1033
+ if (capturedResult.status === 'ok') {
1034
+ rejectUnknownKeys(
1035
+ capturedResult,
1036
+ ['status', 'playbookId', 'childSessionId', 'state', 'output'],
1037
+ 'playbook result',
1038
+ );
1039
+ requireNonEmptyString(
1040
+ capturedResult.childSessionId,
1041
+ 'playbook result childSessionId',
1042
+ );
1043
+ } else {
1044
+ rejectUnknownKeys(
1045
+ capturedResult,
1046
+ ['status', 'playbookId', 'childSessionId', 'state', 'error'],
1047
+ 'playbook result',
1048
+ );
1049
+ }
1050
+ if (capturedResult.status !== 'ok' && own(capturedResult, 'childSessionId')) {
1051
+ requireNonEmptyString(
1052
+ capturedResult.childSessionId,
1053
+ 'playbook result childSessionId',
1054
+ );
1055
+ }
1056
+ if (
1057
+ expectedChildSessionId !== undefined &&
1058
+ capturedResult.childSessionId !== expectedChildSessionId
1059
+ ) {
1060
+ throw new PlaybookCallIdentityError(
1061
+ `playbook result child session ${String(capturedResult.childSessionId)} does not match ${expectedChildSessionId}`,
1062
+ );
1063
+ }
1064
+ if (own(capturedResult, 'state'))
1065
+ validateState(capturedResult.state, 'playbook result state');
1066
+ if (capturedResult.status === 'error' && !own(capturedResult, 'error')) {
1067
+ throw new TypeError('playbook error result requires a normalized error');
1068
+ }
1069
+ if (capturedResult.status !== 'ok' && capturedResult.error !== undefined) {
1070
+ validateNormalizedError(capturedResult.error, 'playbook result error');
1071
+ }
1072
+ return capturedResult as unknown as PlaybookCallResult;
1073
+ }
1074
+
1075
+ class PlaybookCallIdentityError extends TypeError {}
1076
+
1077
+ export function validatePlaybookCallStart(
1078
+ start: unknown,
1079
+ expectedPlaybookId: string,
1080
+ ): PlaybookCallStart {
1081
+ const capturedStart = snapshotJsonValue(start, 'playbook call start');
1082
+ if (!isRecord(capturedStart)) {
1083
+ throw new TypeError('playbook call start must be an object');
1084
+ }
1085
+ if (capturedStart.state === 'settled') {
1086
+ rejectUnknownKeys(
1087
+ capturedStart,
1088
+ ['state', 'result'],
1089
+ 'playbook call start',
1090
+ );
1091
+ return Object.freeze({
1092
+ state: 'settled',
1093
+ result: validatePlaybookCallResult(
1094
+ capturedStart.result,
1095
+ expectedPlaybookId,
1096
+ ),
1097
+ });
1098
+ }
1099
+ if (capturedStart.state === 'suspended') {
1100
+ rejectUnknownKeys(
1101
+ capturedStart,
1102
+ ['state', 'childSessionId'],
1103
+ 'playbook call start',
1104
+ );
1105
+ const childSessionId = requireNonEmptyString(
1106
+ capturedStart.childSessionId,
1107
+ 'playbook call start childSessionId',
1108
+ );
1109
+ return Object.freeze({
1110
+ state: 'suspended',
1111
+ childSessionId,
1112
+ });
1113
+ }
1114
+ throw new TypeError('playbook call start state is invalid');
1115
+ }
1116
+
1117
+ function assignedChildSessionId(start: unknown): string | undefined {
1118
+ if (!isRecord(start)) return undefined;
1119
+ try {
1120
+ const startDescriptors = Object.getOwnPropertyDescriptors(start);
1121
+ const state = capturedDataValue(
1122
+ startDescriptors,
1123
+ 'state',
1124
+ 'playbook call start state',
1125
+ false,
1126
+ );
1127
+ if (state === 'suspended') {
1128
+ const childSessionId = capturedDataValue(
1129
+ startDescriptors,
1130
+ 'childSessionId',
1131
+ 'playbook call start childSessionId',
1132
+ false,
1133
+ );
1134
+ return typeof childSessionId === 'string' &&
1135
+ childSessionId.trim().length > 0
1136
+ ? childSessionId
1137
+ : undefined;
1138
+ }
1139
+ if (state !== 'settled') return undefined;
1140
+ const result = capturedDataValue(
1141
+ startDescriptors,
1142
+ 'result',
1143
+ 'playbook call start result',
1144
+ false,
1145
+ );
1146
+ if (!isRecord(result)) return undefined;
1147
+ const resultDescriptors = Object.getOwnPropertyDescriptors(result);
1148
+ const childSessionId = capturedDataValue(
1149
+ resultDescriptors,
1150
+ 'childSessionId',
1151
+ 'playbook result childSessionId',
1152
+ false,
1153
+ );
1154
+ return typeof childSessionId === 'string' &&
1155
+ childSessionId.trim().length > 0
1156
+ ? childSessionId
1157
+ : undefined;
1158
+ } catch {
1159
+ // Cleanup identity is best effort for malformed or accessor-backed input.
1160
+ return undefined;
1161
+ }
1162
+ }
1163
+
1164
+ function resultFromThrown(
1165
+ playbookId: string,
1166
+ childSessionId: string | undefined,
1167
+ error: unknown,
1168
+ aborted: boolean,
1169
+ ): PlaybookCallResult {
1170
+ const normalized = normalizeError(error);
1171
+ const result: PlaybookCallResult = aborted
1172
+ ? {
1173
+ status: 'aborted',
1174
+ playbookId,
1175
+ ...(childSessionId ? { childSessionId } : {}),
1176
+ error: normalized,
1177
+ }
1178
+ : {
1179
+ status: 'error',
1180
+ playbookId,
1181
+ ...(childSessionId ? { childSessionId } : {}),
1182
+ error: normalized,
1183
+ };
1184
+ return snapshotJsonValue(
1185
+ result,
1186
+ 'playbook result',
1187
+ ) as unknown as PlaybookCallResult;
1188
+ }
1189
+
1190
+ function outputOrThrow(result: PlaybookCallResult): JsonValue | undefined {
1191
+ if (result.status === 'ok') return result.output;
1192
+ throw new NestedPlaybookCallError(result);
1193
+ }
1194
+
1195
+ export function createNestedPlaybookBridge<
1196
+ TInput extends NestedPlaybookInput = NestedPlaybookInput,
1197
+ >(options: NestedPlaybookBridgeOptions): NestedPlaybookBridge<TInput> {
1198
+ let current: ActiveCall | undefined;
1199
+ let disposed = false;
1200
+ const usedCallIds = new Set<string>();
1201
+ const pendingListeners = new Set<
1202
+ (pendingCall: PlaybookPendingCall) => void
1203
+ >();
1204
+
1205
+ const reportBackgroundError = (error: unknown): void => {
1206
+ try {
1207
+ options.onBackgroundError?.(error);
1208
+ } catch {
1209
+ // Background observers are a terminal sink and cannot own cleanup.
1210
+ }
1211
+ };
1212
+
1213
+ const reportControlPlaneError = (error: unknown): void => {
1214
+ try {
1215
+ options.onControlPlaneError?.(error);
1216
+ } catch (callbackError) {
1217
+ // Observability callbacks must never prevent terminal cleanup of the
1218
+ // invocation they are observing.
1219
+ reportBackgroundError(callbackError);
1220
+ }
1221
+ };
1222
+
1223
+ const rejectControlPlane = (error: unknown): never => {
1224
+ reportControlPlaneError(error);
1225
+ throw error;
1226
+ };
1227
+
1228
+ const pendingIdentity = (
1229
+ active: ActiveCall | undefined,
1230
+ ): PlaybookPendingCall | undefined =>
1231
+ active?.phase === 'suspended' && active.childSessionId
1232
+ ? {
1233
+ callId: active.callId,
1234
+ playbookId: active.input.playbookId,
1235
+ childSessionId: active.childSessionId,
1236
+ }
1237
+ : undefined;
1238
+
1239
+ const clear = (active: ActiveCall): void => {
1240
+ if (active.abortListener) {
1241
+ active.signal.removeEventListener('abort', active.abortListener);
1242
+ }
1243
+ if (current === active) current = undefined;
1244
+ };
1245
+
1246
+ const emitFinish = async (
1247
+ active: ActiveCall,
1248
+ result: PlaybookCallResult,
1249
+ ): Promise<void> => {
1250
+ await options.emitFinished({
1251
+ callId: active.callId,
1252
+ stateId: active.input.stateId,
1253
+ playbookId: active.input.playbookId,
1254
+ text: active.input.text,
1255
+ result,
1256
+ });
1257
+ await options.drain();
1258
+ };
1259
+
1260
+ const finishImmediate = async (
1261
+ active: ActiveCall,
1262
+ result: PlaybookCallResult,
1263
+ controlError?: unknown,
1264
+ resultAfterAbortCleanup?: () => PlaybookCallResult,
1265
+ ): Promise<JsonValue | undefined> => {
1266
+ let effectiveResult = result;
1267
+ let cleanupControlError: unknown;
1268
+ if (result.status === 'aborted' || active.signal.aborted) {
1269
+ try {
1270
+ await drainPlaybookAbortCleanups(active.signal);
1271
+ } catch (error) {
1272
+ cleanupControlError = error;
1273
+ reportControlPlaneError(error);
1274
+ effectiveResult = resultFromThrown(
1275
+ active.input.playbookId,
1276
+ active.childSessionId,
1277
+ error,
1278
+ false,
1279
+ );
1280
+ }
1281
+ if (cleanupControlError === undefined && resultAfterAbortCleanup) {
1282
+ effectiveResult = resultAfterAbortCleanup();
1283
+ }
1284
+ }
1285
+ let finishControlError: unknown;
1286
+ try {
1287
+ await emitFinish(active, effectiveResult);
1288
+ } catch (error) {
1289
+ reportControlPlaneError(error);
1290
+ finishControlError = error;
1291
+ } finally {
1292
+ // An immediate call can never be resumed. Even when its finish
1293
+ // emission fails, do not leave a permanently unresumable call in the
1294
+ // bridge and prevent disposal or a later invocation.
1295
+ clear(active);
1296
+ }
1297
+ if (controlError !== undefined) throw controlError;
1298
+ if (cleanupControlError !== undefined) throw cleanupControlError;
1299
+ if (finishControlError !== undefined) throw finishControlError;
1300
+ return outputOrThrow(effectiveResult);
1301
+ };
1302
+
1303
+ const settlePending = async (
1304
+ active: ActiveCall,
1305
+ result: PlaybookCallResult,
1306
+ controlError?: unknown,
1307
+ ): Promise<void> => {
1308
+ if (active.phase === 'settling' && active.settlement) {
1309
+ await active.settlement;
1310
+ return;
1311
+ }
1312
+ if (active.phase !== 'suspended') {
1313
+ throw new Error(`playbook call ${active.callId} is not suspended`);
1314
+ }
1315
+ active.phase = 'settling';
1316
+ const settlement = (async (): Promise<void> => {
1317
+ let effectiveResult = result;
1318
+ let cleanupControlError: unknown;
1319
+ if (result.status === 'aborted' || active.signal.aborted) {
1320
+ if (result.status !== 'aborted' && active.signal.aborted) {
1321
+ effectiveResult = resultFromThrown(
1322
+ active.input.playbookId,
1323
+ active.childSessionId,
1324
+ active.signal.reason ??
1325
+ new Error('Nested playbook invocation aborted'),
1326
+ true,
1327
+ );
1328
+ }
1329
+ try {
1330
+ await drainPlaybookAbortCleanups(active.signal);
1331
+ } catch (cleanupError) {
1332
+ cleanupControlError = cleanupError;
1333
+ reportControlPlaneError(cleanupError);
1334
+ effectiveResult = resultFromThrown(
1335
+ active.input.playbookId,
1336
+ active.childSessionId,
1337
+ cleanupError,
1338
+ false,
1339
+ );
1340
+ }
1341
+ }
1342
+ try {
1343
+ await emitFinish(active, effectiveResult);
1344
+ } catch (error) {
1345
+ // A finish event is the durable return boundary. If it cannot be
1346
+ // emitted and drained, the child result must not remain retryable:
1347
+ // clear the identity and fail the promise actor so its parent takes
1348
+ // onError instead of observing a phantom suspended child.
1349
+ reportControlPlaneError(error);
1350
+ clear(active);
1351
+ active.deferred.reject(error);
1352
+ throw error;
1353
+ }
1354
+ clear(active);
1355
+ if (controlError !== undefined) {
1356
+ active.deferred.reject(controlError);
1357
+ } else if (cleanupControlError !== undefined) {
1358
+ active.deferred.reject(cleanupControlError);
1359
+ } else if (effectiveResult.status === 'ok') {
1360
+ active.deferred.resolve(effectiveResult.output);
1361
+ } else {
1362
+ active.deferred.reject(new NestedPlaybookCallError(effectiveResult));
1363
+ }
1364
+ if (cleanupControlError !== undefined) throw cleanupControlError;
1365
+ })();
1366
+ active.settlement = settlement;
1367
+ try {
1368
+ await settlement;
1369
+ } catch (error) {
1370
+ await active.finished.promise;
1371
+ throw error;
1372
+ }
1373
+ };
1374
+
1375
+ const actorLogic = fromPromise<JsonValue | undefined, TInput>(
1376
+ async ({ input, signal: invocationSignal }) => {
1377
+ if (disposed) {
1378
+ rejectControlPlane(new Error('nested playbook bridge is disposed'));
1379
+ }
1380
+ if (current) {
1381
+ rejectControlPlane(
1382
+ new Error(`playbook call ${current.callId} is already outstanding`),
1383
+ );
1384
+ }
1385
+ const [normalizedInput, callId] = (() => {
1386
+ try {
1387
+ return [
1388
+ {
1389
+ stateId: requireNonEmptyString(
1390
+ input.stateId,
1391
+ 'playbook input stateId',
1392
+ ),
1393
+ playbookId: requireNonEmptyString(
1394
+ input.playbookId,
1395
+ 'playbook input playbookId',
1396
+ ),
1397
+ text: requireNonEmptyString(input.text, 'playbook input text'),
1398
+ },
1399
+ requireNonEmptyString(
1400
+ options.nextCallId(),
1401
+ 'allocated playbook call id',
1402
+ ),
1403
+ ] as const;
1404
+ } catch (error) {
1405
+ return rejectControlPlane(error);
1406
+ }
1407
+ })();
1408
+ if (usedCallIds.has(callId)) {
1409
+ rejectControlPlane(
1410
+ new Error(`allocated duplicate playbook call id ${callId}`),
1411
+ );
1412
+ }
1413
+ usedCallIds.add(callId);
1414
+ const controller = new AbortController();
1415
+ let callSignal: AbortSignal;
1416
+ try {
1417
+ callSignal = combineAbortSignals(
1418
+ invocationSignal,
1419
+ options.getBoundarySignal?.(),
1420
+ controller.signal,
1421
+ );
1422
+ } catch (error) {
1423
+ return rejectControlPlane(error);
1424
+ }
1425
+ const active: ActiveCall = {
1426
+ callId,
1427
+ input: normalizedInput,
1428
+ deferred: deferred<JsonValue | undefined>(),
1429
+ finished: deferred<void>(),
1430
+ controller,
1431
+ signal: callSignal,
1432
+ phase: 'starting',
1433
+ };
1434
+ current = active;
1435
+
1436
+ try {
1437
+ // Promise actors may begin before XState publishes the root snapshot
1438
+ // for their entering state. Yield through the runtime's global queue so
1439
+ // that transition/status telemetry is enqueued before call.started.
1440
+ try {
1441
+ await options.drain();
1442
+ } catch (error) {
1443
+ reportControlPlaneError(error);
1444
+ clear(active);
1445
+ throw error;
1446
+ }
1447
+ try {
1448
+ await options.emitStarted({ callId, ...normalizedInput });
1449
+ } catch (error) {
1450
+ reportControlPlaneError(error);
1451
+ return await finishImmediate(
1452
+ active,
1453
+ resultFromThrown(
1454
+ normalizedInput.playbookId,
1455
+ undefined,
1456
+ error,
1457
+ false,
1458
+ ),
1459
+ error,
1460
+ );
1461
+ }
1462
+ try {
1463
+ await options.drain();
1464
+ } catch (error) {
1465
+ reportControlPlaneError(error);
1466
+ return await finishImmediate(
1467
+ active,
1468
+ resultFromThrown(
1469
+ normalizedInput.playbookId,
1470
+ undefined,
1471
+ error,
1472
+ false,
1473
+ ),
1474
+ error,
1475
+ );
1476
+ }
1477
+
1478
+ const request: PlaybookCallRequest = {
1479
+ callId,
1480
+ playbookId: normalizedInput.playbookId,
1481
+ text: normalizedInput.text,
1482
+ };
1483
+ let rawStart: unknown;
1484
+ let observedStart: unknown;
1485
+ let startSettled = false;
1486
+ let removeOpeningAbortListener = (): void => undefined;
1487
+ try {
1488
+ if (active.signal.aborted) throw active.signal.reason;
1489
+ const starting = options.callPlaybook(request, active.signal).then(
1490
+ (value) => {
1491
+ observedStart = value;
1492
+ startSettled = true;
1493
+ return value;
1494
+ },
1495
+ (error: unknown) => {
1496
+ startSettled = true;
1497
+ throw error;
1498
+ },
1499
+ );
1500
+ const openingCleanup = starting.then(
1501
+ () => undefined,
1502
+ (error: unknown) => {
1503
+ if (isAbortReason(error, active.signal)) return;
1504
+ throw error;
1505
+ },
1506
+ );
1507
+ void openingCleanup.catch(() => undefined);
1508
+ const registerOpeningCleanup = (): void =>
1509
+ registerPlaybookAbortCleanup(active.signal, openingCleanup);
1510
+ removeOpeningAbortListener = (): void =>
1511
+ active.signal.removeEventListener('abort', registerOpeningCleanup);
1512
+ if (active.signal.aborted) registerOpeningCleanup();
1513
+ else {
1514
+ active.signal.addEventListener('abort', registerOpeningCleanup, {
1515
+ once: true,
1516
+ });
1517
+ }
1518
+ rawStart = await withAbort(starting, active.signal);
1519
+ } catch (error) {
1520
+ const controlError = active.signal.aborted ? undefined : error;
1521
+ if (controlError !== undefined) reportControlPlaneError(controlError);
1522
+ const result = resultFromThrown(
1523
+ normalizedInput.playbookId,
1524
+ undefined,
1525
+ error,
1526
+ active.signal.aborted,
1527
+ );
1528
+ return await finishImmediate(
1529
+ active,
1530
+ result,
1531
+ controlError,
1532
+ active.signal.aborted
1533
+ ? () =>
1534
+ resultFromThrown(
1535
+ normalizedInput.playbookId,
1536
+ startSettled
1537
+ ? assignedChildSessionId(observedStart)
1538
+ : undefined,
1539
+ error,
1540
+ true,
1541
+ )
1542
+ : undefined,
1543
+ );
1544
+ } finally {
1545
+ removeOpeningAbortListener();
1546
+ }
1547
+
1548
+ let start: PlaybookCallStart;
1549
+ try {
1550
+ start = validatePlaybookCallStart(
1551
+ rawStart,
1552
+ normalizedInput.playbookId,
1553
+ );
1554
+ } catch (error) {
1555
+ reportControlPlaneError(error);
1556
+ const childSessionId = assignedChildSessionId(rawStart);
1557
+ active.childSessionId = childSessionId;
1558
+ if (
1559
+ isRecord(rawStart) &&
1560
+ rawStart.state === 'suspended' &&
1561
+ !active.controller.signal.aborted
1562
+ ) {
1563
+ // The host may already have opened a child before returning this
1564
+ // malformed suspended start. Abort the same signal it received so
1565
+ // its registered child cleanup drains before the parent finish.
1566
+ active.controller.abort(error);
1567
+ }
1568
+ return await finishImmediate(
1569
+ active,
1570
+ resultFromThrown(
1571
+ normalizedInput.playbookId,
1572
+ childSessionId,
1573
+ error,
1574
+ false,
1575
+ ),
1576
+ error,
1577
+ );
1578
+ }
1579
+
1580
+ if (active.signal.aborted) {
1581
+ return await finishImmediate(
1582
+ active,
1583
+ resultFromThrown(
1584
+ normalizedInput.playbookId,
1585
+ start.state === 'suspended' ? start.childSessionId : undefined,
1586
+ active.signal.reason,
1587
+ true,
1588
+ ),
1589
+ );
1590
+ }
1591
+ if (start.state === 'settled') {
1592
+ return await finishImmediate(active, start.result);
1593
+ }
1594
+
1595
+ active.phase = 'suspended';
1596
+ active.childSessionId = start.childSessionId;
1597
+ const abortListener = (): void => {
1598
+ if (active.phase !== 'suspended') return;
1599
+ const result = resultFromThrown(
1600
+ active.input.playbookId,
1601
+ active.childSessionId,
1602
+ active.signal.reason ??
1603
+ new Error('Nested playbook invocation aborted'),
1604
+ true,
1605
+ );
1606
+ void settlePending(active, result).catch((error: unknown) => {
1607
+ reportBackgroundError(error);
1608
+ });
1609
+ };
1610
+ active.abortListener = abortListener;
1611
+ active.signal.addEventListener('abort', abortListener, { once: true });
1612
+ const pendingCall = pendingIdentity(active);
1613
+ if (!pendingCall) {
1614
+ throw new Error('suspended call identity was not recorded');
1615
+ }
1616
+ for (const listener of pendingListeners) {
1617
+ try {
1618
+ listener(pendingCall);
1619
+ } catch (error) {
1620
+ reportBackgroundError(error);
1621
+ }
1622
+ }
1623
+ if (active.signal.aborted) abortListener();
1624
+ return await active.deferred.promise;
1625
+ } catch (error) {
1626
+ active.runError = error;
1627
+ throw error;
1628
+ } finally {
1629
+ active.finished.resolve(undefined);
1630
+ }
1631
+ },
1632
+ );
1633
+
1634
+ const abortPending = async (
1635
+ error: unknown = new Error('Nested playbook call aborted'),
1636
+ ): Promise<void> => {
1637
+ const active = current;
1638
+ if (!active) return;
1639
+ if (!active.controller.signal.aborted) active.controller.abort(error);
1640
+ if (active.phase === 'starting') {
1641
+ await active.finished.promise;
1642
+ if (
1643
+ active.runError !== undefined &&
1644
+ !(active.runError instanceof NestedPlaybookCallError)
1645
+ ) {
1646
+ throw active.runError;
1647
+ }
1648
+ return;
1649
+ }
1650
+ if (active.phase === 'settling' && active.settlement) {
1651
+ await active.settlement;
1652
+ await active.finished.promise;
1653
+ return;
1654
+ }
1655
+ const pendingCall = pendingIdentity(active);
1656
+ if (!pendingCall) return;
1657
+ await settlePending(
1658
+ active,
1659
+ resultFromThrown(
1660
+ pendingCall.playbookId,
1661
+ pendingCall.childSessionId,
1662
+ error,
1663
+ true,
1664
+ ),
1665
+ );
1666
+ await active.finished.promise;
1667
+ };
1668
+
1669
+ return {
1670
+ actorLogic,
1671
+ getPendingCall: () => pendingIdentity(current),
1672
+ subscribePendingCall(listener) {
1673
+ if (disposed) return () => undefined;
1674
+ pendingListeners.add(listener);
1675
+ const pendingCall = pendingIdentity(current);
1676
+ if (pendingCall) {
1677
+ try {
1678
+ listener(pendingCall);
1679
+ } catch (error) {
1680
+ reportBackgroundError(error);
1681
+ }
1682
+ }
1683
+ return () => pendingListeners.delete(listener);
1684
+ },
1685
+ async resume({ callId, result, signal }) {
1686
+ const active = current;
1687
+ const pendingCall = pendingIdentity(active);
1688
+ if (!active || !pendingCall) {
1689
+ throw new Error(`unknown or stale playbook call id ${callId}`);
1690
+ }
1691
+ if (callId !== pendingCall.callId) {
1692
+ const error = new PlaybookCallIdentityError(
1693
+ `playbook call id ${callId} does not match ${pendingCall.callId}`,
1694
+ );
1695
+ reportControlPlaneError(error);
1696
+ throw error;
1697
+ }
1698
+ let validatedResult: PlaybookCallResult;
1699
+ try {
1700
+ validatedResult = validatePlaybookCallResult(
1701
+ result,
1702
+ pendingCall.playbookId,
1703
+ pendingCall.childSessionId,
1704
+ );
1705
+ } catch (error) {
1706
+ reportControlPlaneError(error);
1707
+ if (!(error instanceof PlaybookCallIdentityError)) {
1708
+ await settlePending(
1709
+ active,
1710
+ resultFromThrown(
1711
+ pendingCall.playbookId,
1712
+ pendingCall.childSessionId,
1713
+ error,
1714
+ false,
1715
+ ),
1716
+ error,
1717
+ );
1718
+ await active.finished.promise;
1719
+ }
1720
+ throw error;
1721
+ }
1722
+ options.bindResumeSignal?.(signal);
1723
+ await settlePending(active, validatedResult);
1724
+ },
1725
+ abortPending,
1726
+ async dispose() {
1727
+ if (disposed) return;
1728
+ disposed = true;
1729
+ try {
1730
+ const active = current;
1731
+ await abortPending(new Error('Nested playbook bridge disposed'));
1732
+ if (
1733
+ active?.runError !== undefined &&
1734
+ !(active.runError instanceof NestedPlaybookCallError)
1735
+ ) {
1736
+ throw active.runError;
1737
+ }
1738
+ } finally {
1739
+ pendingListeners.clear();
1740
+ }
1741
+ },
1742
+ };
1743
+ }
1744
+
1745
+ export interface WaitForPlaybookQuiescenceOptions {
1746
+ signal?: AbortSignal;
1747
+ timeout?: number;
1748
+ pendingCalls?: PendingCallObserver;
1749
+ }
1750
+
1751
+ /**
1752
+ * Wait at the imperative runtime boundary; workflow waiting remains in XState.
1753
+ * A pending-call notification covers the case where the suspended state was
1754
+ * entered before its host returned the child session id.
1755
+ */
1756
+ export async function waitForPlaybookQuiescence<TActorRef extends AnyActorRef>(
1757
+ actor: TActorRef,
1758
+ options: WaitForPlaybookQuiescenceOptions = {},
1759
+ ): Promise<SnapshotFrom<TActorRef>> {
1760
+ const current = actor.getSnapshot();
1761
+ const normalized = normalizePlaybookSnapshot(current, {
1762
+ pendingCall: options.pendingCalls?.getPendingCall(),
1763
+ });
1764
+ if (normalized.quiescent) return current;
1765
+
1766
+ const waitOptions = {
1767
+ ...(options.timeout === undefined ? {} : { timeout: options.timeout }),
1768
+ };
1769
+ const waitController = options.pendingCalls
1770
+ ? new AbortController()
1771
+ : undefined;
1772
+ const forwardAbort = (): void =>
1773
+ waitController?.abort(options.signal?.reason);
1774
+ if (waitController && options.signal) {
1775
+ if (options.signal.aborted) forwardAbort();
1776
+ else options.signal.addEventListener('abort', forwardAbort, { once: true });
1777
+ }
1778
+ const snapshotWait = waitFor(
1779
+ actor,
1780
+ (snapshot) =>
1781
+ normalizePlaybookSnapshot(snapshot, {
1782
+ pendingCall: options.pendingCalls?.getPendingCall(),
1783
+ }).quiescent,
1784
+ {
1785
+ ...waitOptions,
1786
+ ...(waitController
1787
+ ? { signal: waitController.signal }
1788
+ : options.signal
1789
+ ? { signal: options.signal }
1790
+ : {}),
1791
+ },
1792
+ );
1793
+ if (!options.pendingCalls) return snapshotWait;
1794
+
1795
+ let unsubscribe = (): void => undefined;
1796
+ const pendingWait = new Promise<SnapshotFrom<TActorRef>>((resolve) => {
1797
+ unsubscribe =
1798
+ options.pendingCalls?.subscribePendingCall(() => {
1799
+ const snapshot = actor.getSnapshot();
1800
+ if (
1801
+ normalizePlaybookSnapshot(snapshot, {
1802
+ pendingCall: options.pendingCalls?.getPendingCall(),
1803
+ }).quiescent
1804
+ ) {
1805
+ resolve(snapshot);
1806
+ }
1807
+ }) ?? unsubscribe;
1808
+ });
1809
+ try {
1810
+ return await Promise.race([snapshotWait, pendingWait]);
1811
+ } finally {
1812
+ unsubscribe();
1813
+ waitController?.abort(new Error('Playbook quiescence already settled'));
1814
+ options.signal?.removeEventListener('abort', forwardAbort);
1815
+ }
1816
+ }