@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,1258 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ import { fromPromise, waitFor, } from 'xstate';
4
+ // DR-019: the generic linked-runtime factory and its strategy helpers live
5
+ // in the sibling module and are re-exported here so linked artifacts import
6
+ // one shared engine surface.
7
+ export * from './xstate-playbook-runtime.js';
8
+ const BUSY_TAG = 'playbook.busy';
9
+ const SUSPENDED_TAG = 'playbook.suspended';
10
+ function deferred() {
11
+ let resolve;
12
+ let reject;
13
+ const promise = new Promise((resolvePromise, rejectPromise) => {
14
+ resolve = resolvePromise;
15
+ reject = rejectPromise;
16
+ });
17
+ return { promise, resolve, reject };
18
+ }
19
+ function withAbort(promise, signal) {
20
+ if (signal.aborted)
21
+ return Promise.reject(signal.reason);
22
+ return new Promise((resolve, reject) => {
23
+ const onAbort = () => reject(signal.reason);
24
+ signal.addEventListener('abort', onAbort, { once: true });
25
+ void promise.then((value) => {
26
+ signal.removeEventListener('abort', onAbort);
27
+ resolve(value);
28
+ }, (error) => {
29
+ signal.removeEventListener('abort', onAbort);
30
+ reject(error);
31
+ });
32
+ });
33
+ }
34
+ function isAbortReason(error, signal) {
35
+ return (signal.aborted &&
36
+ (error === signal.reason || normalizeError(error).name === 'AbortError'));
37
+ }
38
+ const NEVER_ABORTED_SIGNAL = new AbortController().signal;
39
+ /**
40
+ * Compose invocation-lifetime and imperative-boundary cancellation without
41
+ * installing a second forwarding listener in each generated runtime.
42
+ */
43
+ export function combineAbortSignals(...signals) {
44
+ const present = [];
45
+ for (const [index, signal] of signals.entries()) {
46
+ if (signal === undefined)
47
+ continue;
48
+ if (!(signal instanceof AbortSignal)) {
49
+ throw new TypeError(`abort signal ${index} must be an AbortSignal`);
50
+ }
51
+ present.push(signal);
52
+ }
53
+ if (present.length === 0)
54
+ return NEVER_ABORTED_SIGNAL;
55
+ if (present.length === 1)
56
+ return present[0];
57
+ return AbortSignal.any(present);
58
+ }
59
+ const abortCleanups = new WeakMap();
60
+ /**
61
+ * Register host cleanup started synchronously by an invocation abort.
62
+ * The nested bridge drains these promises before it publishes the matching
63
+ * call-finish boundary, without widening the public six-port contract.
64
+ */
65
+ export function registerPlaybookAbortCleanup(signal, cleanup) {
66
+ let pending = abortCleanups.get(signal);
67
+ if (!pending) {
68
+ pending = new Set();
69
+ abortCleanups.set(signal, pending);
70
+ }
71
+ pending.add(cleanup);
72
+ // Mark rejection handled immediately, but retain the settled promise until
73
+ // the bridge's allSettled drain observes its outcome.
74
+ void cleanup.catch(() => undefined);
75
+ }
76
+ async function drainPlaybookAbortCleanups(signal) {
77
+ const failures = [];
78
+ while (true) {
79
+ const pending = abortCleanups.get(signal);
80
+ if (!pending || pending.size === 0)
81
+ break;
82
+ const batch = [...pending];
83
+ pending.clear();
84
+ const outcomes = await Promise.allSettled(batch);
85
+ for (const outcome of outcomes) {
86
+ if (outcome.status === 'rejected')
87
+ failures.push(outcome.reason);
88
+ }
89
+ }
90
+ abortCleanups.delete(signal);
91
+ if (failures.length === 1)
92
+ throw failures[0];
93
+ if (failures.length > 1) {
94
+ throw new AggregateError(failures, 'playbook abort cleanup failed');
95
+ }
96
+ }
97
+ function isRecord(value) {
98
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
99
+ return false;
100
+ }
101
+ const prototype = Object.getPrototypeOf(value);
102
+ return prototype === Object.prototype || prototype === null;
103
+ }
104
+ function own(value, key) {
105
+ return Object.prototype.hasOwnProperty.call(value, key);
106
+ }
107
+ /**
108
+ * Validate and detach one JSON value from the exact property descriptors that
109
+ * were inspected. Reading `value[key]` after validation would let a Proxy
110
+ * substitute a different value between the check and the clone.
111
+ */
112
+ function snapshotJsonValueFromDescriptors(value, path = '$', ancestors = new Set()) {
113
+ if (value === null ||
114
+ typeof value === 'string' ||
115
+ typeof value === 'boolean') {
116
+ return value;
117
+ }
118
+ if (typeof value === 'number') {
119
+ if (!Number.isFinite(value)) {
120
+ throw new TypeError(`${path} must contain a finite JSON number`);
121
+ }
122
+ if (Object.is(value, -0)) {
123
+ throw new TypeError(`${path} must not contain negative zero`);
124
+ }
125
+ return value;
126
+ }
127
+ if (Array.isArray(value)) {
128
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
129
+ throw new TypeError(`${path} must be a plain JSON array`);
130
+ }
131
+ if (ancestors.has(value)) {
132
+ throw new TypeError(`${path} must not contain a JSON cycle`);
133
+ }
134
+ const nextAncestors = new Set(ancestors).add(value);
135
+ const descriptors = Object.getOwnPropertyDescriptors(value);
136
+ const descriptorMap = descriptors;
137
+ const descriptorKeys = Reflect.ownKeys(descriptors);
138
+ if (descriptorKeys.some((key) => typeof key === 'symbol')) {
139
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
140
+ }
141
+ const lengthDescriptor = descriptorMap.length;
142
+ if (!lengthDescriptor ||
143
+ !own(lengthDescriptor, 'value') ||
144
+ !Number.isSafeInteger(lengthDescriptor.value) ||
145
+ lengthDescriptor.value < 0) {
146
+ throw new TypeError(`${path} must be a plain JSON array`);
147
+ }
148
+ const length = lengthDescriptor.value;
149
+ const indexed = [];
150
+ for (const key of descriptorKeys) {
151
+ if (typeof key === 'symbol')
152
+ continue;
153
+ if (key === 'length')
154
+ continue;
155
+ const descriptor = descriptorMap[key];
156
+ if (!descriptor)
157
+ continue;
158
+ const index = Number(key);
159
+ if (!Number.isSafeInteger(index) ||
160
+ index < 0 ||
161
+ index >= length ||
162
+ String(index) !== key) {
163
+ throw new TypeError(`${path}.${key} is not a JSON array index`);
164
+ }
165
+ indexed.push([index, descriptor]);
166
+ }
167
+ if (indexed.length !== length) {
168
+ throw new TypeError(`${path} must not be a sparse JSON array`);
169
+ }
170
+ indexed.sort(([left], [right]) => left - right);
171
+ const copy = [];
172
+ for (const [index, descriptor] of indexed) {
173
+ if (!descriptor.enumerable) {
174
+ throw new TypeError(`${path}[${index}] must be an enumerable JSON property`);
175
+ }
176
+ if (!own(descriptor, 'value')) {
177
+ throw new TypeError(`${path}[${index}] must be a JSON data property`);
178
+ }
179
+ copy.push(snapshotJsonValueFromDescriptors(descriptor.value, `${path}[${index}]`, nextAncestors));
180
+ }
181
+ return Object.freeze(copy);
182
+ }
183
+ if (!isRecord(value)) {
184
+ throw new TypeError(`${path} must be a JSON value`);
185
+ }
186
+ if (ancestors.has(value)) {
187
+ throw new TypeError(`${path} must not contain a JSON cycle`);
188
+ }
189
+ const descriptors = Object.getOwnPropertyDescriptors(value);
190
+ const descriptorKeys = Reflect.ownKeys(descriptors);
191
+ if (descriptorKeys.some((key) => typeof key === 'symbol')) {
192
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
193
+ }
194
+ const nextAncestors = new Set(ancestors).add(value);
195
+ const copy = {};
196
+ for (const key of descriptorKeys) {
197
+ if (typeof key === 'symbol')
198
+ continue;
199
+ const descriptor = descriptors[key];
200
+ if (!descriptor)
201
+ continue;
202
+ if (!descriptor.enumerable) {
203
+ throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
204
+ }
205
+ if (!own(descriptor, 'value')) {
206
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
207
+ }
208
+ defineEnumerableDataProperty(copy, key, snapshotJsonValueFromDescriptors(descriptor.value, `${path}.${key}`, nextAncestors));
209
+ }
210
+ return Object.freeze(copy);
211
+ }
212
+ /** Reject values that would be changed, omitted, or rejected by JSON. */
213
+ export function assertJsonSafe(value, path = '$', ancestors = new Set()) {
214
+ snapshotJsonValueFromDescriptors(value, path, ancestors);
215
+ }
216
+ function defineEnumerableDataProperty(target, key, value) {
217
+ // Assignment to Object.prototype's legacy `__proto__` setter changes a
218
+ // clone's prototype and silently drops the JSON member. Defining an own data
219
+ // property preserves every valid JSON key while retaining an ordinary object
220
+ // prototype for callers.
221
+ Object.defineProperty(target, key, {
222
+ value,
223
+ enumerable: true,
224
+ configurable: true,
225
+ writable: true,
226
+ });
227
+ }
228
+ /** Validate, detach, and recursively freeze host-owned JSON input. */
229
+ export function snapshotJsonValue(value, path = '$') {
230
+ return snapshotJsonValueFromDescriptors(value, path);
231
+ }
232
+ function capturedDataValue(descriptors, key, path, required = true) {
233
+ const descriptor = descriptors[key];
234
+ if (!descriptor) {
235
+ if (required)
236
+ throw new TypeError(`${path} must be an own data property`);
237
+ return undefined;
238
+ }
239
+ if (!own(descriptor, 'value')) {
240
+ throw new TypeError(`${path} must be an own data property`);
241
+ }
242
+ return descriptor.value;
243
+ }
244
+ function capturedPort(descriptors, name) {
245
+ const value = capturedDataValue(descriptors, name, `playbook session ports.${name}`);
246
+ if (typeof value !== 'function') {
247
+ throw new TypeError(`playbook session ports.${name} must be a function`);
248
+ }
249
+ return value;
250
+ }
251
+ /** Validate session causality and detach its immutable identity from the host. */
252
+ export function snapshotPlaybookSession(session) {
253
+ if (!isRecord(session)) {
254
+ throw new TypeError('playbook session must be an object');
255
+ }
256
+ const sessionDescriptors = Object.getOwnPropertyDescriptors(session);
257
+ const sessionId = requireNonEmptyString(capturedDataValue(sessionDescriptors, 'sessionId', 'playbook session sessionId'), 'playbook session sessionId');
258
+ const playbookId = requireNonEmptyString(capturedDataValue(sessionDescriptors, 'playbookId', 'playbook session playbookId'), 'playbook session playbookId');
259
+ const rootSessionId = requireNonEmptyString(capturedDataValue(sessionDescriptors, 'rootSessionId', 'playbook session rootSessionId'), 'playbook session rootSessionId');
260
+ const capturedDepth = capturedDataValue(sessionDescriptors, 'depth', 'playbook session depth');
261
+ if (!Number.isSafeInteger(capturedDepth) || capturedDepth < 0) {
262
+ throw new TypeError('playbook session depth must be a non-negative integer');
263
+ }
264
+ const depth = capturedDepth;
265
+ const capturedParentSessionId = capturedDataValue(sessionDescriptors, 'parentSessionId', 'playbook session parentSessionId', false);
266
+ const capturedParentCallId = capturedDataValue(sessionDescriptors, 'parentCallId', 'playbook session parentCallId', false);
267
+ const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
268
+ const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
269
+ let parentSessionId;
270
+ let parentCallId;
271
+ if (depth === 0) {
272
+ if (rootSessionId !== sessionId) {
273
+ throw new TypeError('root playbook session must be its own rootSessionId');
274
+ }
275
+ if (hasParentSessionId || hasParentCallId) {
276
+ throw new TypeError('root playbook session must not carry parent identity');
277
+ }
278
+ }
279
+ else {
280
+ parentSessionId = requireNonEmptyString(capturedParentSessionId, 'playbook session parentSessionId');
281
+ parentCallId = requireNonEmptyString(capturedParentCallId, 'playbook session parentCallId');
282
+ if (sessionId === rootSessionId || sessionId === parentSessionId) {
283
+ throw new TypeError('child playbook sessionId must differ from its root and parent session ids');
284
+ }
285
+ }
286
+ const capturedPorts = capturedDataValue(sessionDescriptors, 'ports', 'playbook session ports');
287
+ if (!isRecord(capturedPorts)) {
288
+ throw new TypeError('playbook session ports must be an object');
289
+ }
290
+ const portDescriptors = Object.getOwnPropertyDescriptors(capturedPorts);
291
+ const ports = Object.freeze({
292
+ callPlayer: capturedPort(portDescriptors, 'callPlayer'),
293
+ callCaptain: capturedPort(portDescriptors, 'callCaptain'),
294
+ callJudge: capturedPort(portDescriptors, 'callJudge'),
295
+ callPlaybook: capturedPort(portDescriptors, 'callPlaybook'),
296
+ emitStatus: capturedPort(portDescriptors, 'emitStatus'),
297
+ emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
298
+ });
299
+ return Object.freeze({
300
+ sessionId,
301
+ playbookId,
302
+ rootSessionId,
303
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
304
+ ...(parentCallId === undefined ? {} : { parentCallId }),
305
+ depth,
306
+ ports,
307
+ });
308
+ }
309
+ export function normalizeError(error) {
310
+ if (error instanceof Error) {
311
+ let name = 'Error';
312
+ let message = 'Unknown error';
313
+ let stack;
314
+ try {
315
+ if (typeof error.name === 'string' && error.name.length > 0) {
316
+ name = error.name;
317
+ }
318
+ }
319
+ catch {
320
+ // Keep the stable fallback for hostile Error subclasses.
321
+ }
322
+ try {
323
+ if (typeof error.message === 'string')
324
+ message = error.message;
325
+ }
326
+ catch {
327
+ // Keep the stable fallback for hostile Error subclasses.
328
+ }
329
+ try {
330
+ if (typeof error.stack === 'string')
331
+ stack = error.stack;
332
+ }
333
+ catch {
334
+ // A stack is optional at the public boundary.
335
+ }
336
+ return {
337
+ name,
338
+ message,
339
+ ...(stack ? { stack } : {}),
340
+ };
341
+ }
342
+ if (typeof error === 'string') {
343
+ return { name: 'Error', message: error };
344
+ }
345
+ try {
346
+ assertJsonSafe(error);
347
+ if (isRecord(error) && typeof error.message === 'string') {
348
+ return {
349
+ name: typeof error.name === 'string' && error.name.length > 0
350
+ ? error.name
351
+ : 'Error',
352
+ message: error.message,
353
+ ...(typeof error.stack === 'string' ? { stack: error.stack } : {}),
354
+ };
355
+ }
356
+ return { name: 'Error', message: JSON.stringify(error) };
357
+ }
358
+ catch {
359
+ try {
360
+ return { name: 'Error', message: String(error) };
361
+ }
362
+ catch {
363
+ return { name: 'Error', message: 'Unknown error' };
364
+ }
365
+ }
366
+ }
367
+ function requireNonEmptyString(value, path) {
368
+ if (typeof value !== 'string' || value.trim().length === 0) {
369
+ throw new TypeError(`${path} must be a non-empty string`);
370
+ }
371
+ return value;
372
+ }
373
+ function normalizeStateValue(value, path = 'snapshot.value', ancestors = new Set()) {
374
+ if (typeof value === 'string')
375
+ return value;
376
+ if (!isRecord(value)) {
377
+ throw new TypeError(`${path} must be an XState string or object value`);
378
+ }
379
+ if (ancestors.has(value)) {
380
+ throw new TypeError(`${path} must not contain an XState state cycle`);
381
+ }
382
+ const nextAncestors = new Set(ancestors).add(value);
383
+ const normalized = {};
384
+ for (const key of Object.keys(value).sort()) {
385
+ defineEnumerableDataProperty(normalized, key, normalizeStateValue(value[key], `${path}.${key}`, nextAncestors));
386
+ }
387
+ return normalized;
388
+ }
389
+ function asMachineSnapshot(snapshot) {
390
+ if (!isRecord(snapshot)) {
391
+ throw new TypeError('snapshot must be an XState machine snapshot');
392
+ }
393
+ const status = snapshot.status;
394
+ if (status !== 'active' &&
395
+ status !== 'done' &&
396
+ status !== 'error' &&
397
+ status !== 'stopped') {
398
+ throw new TypeError('snapshot.status is not an XState actor status');
399
+ }
400
+ if (!(snapshot.tags instanceof Set)) {
401
+ throw new TypeError('snapshot.tags must be an XState tag set');
402
+ }
403
+ if (typeof snapshot.getMeta !== 'function') {
404
+ throw new TypeError('snapshot.getMeta must be an XState public method');
405
+ }
406
+ return snapshot;
407
+ }
408
+ /** Read stable state identity without consulting XState's private `_nodes`. */
409
+ export function activePlaybookStateMetadata(snapshot) {
410
+ const machineSnapshot = asMachineSnapshot(snapshot);
411
+ const byStateId = new Map();
412
+ for (const [nodeId, meta] of Object.entries(machineSnapshot.getMeta())) {
413
+ if (!isRecord(meta) || !own(meta, 'playbook'))
414
+ continue;
415
+ if (!isRecord(meta.playbook)) {
416
+ throw new TypeError(`${nodeId}.meta.playbook must be an object`);
417
+ }
418
+ const stateId = requireNonEmptyString(meta.playbook.stateId, `${nodeId}.meta.playbook.stateId`);
419
+ const description = requireNonEmptyString(meta.playbook.description, `${nodeId}.meta.playbook.description`);
420
+ const previous = byStateId.get(stateId);
421
+ if (previous && previous.description !== description) {
422
+ throw new TypeError(`active state id ${stateId} has conflicting descriptions`);
423
+ }
424
+ byStateId.set(stateId, { stateId, description });
425
+ }
426
+ return [...byStateId.values()].sort((left, right) => left.stateId.localeCompare(right.stateId));
427
+ }
428
+ export function normalizePlaybookSnapshot(snapshot, options = {}) {
429
+ const machineSnapshot = asMachineSnapshot(snapshot);
430
+ const active = activePlaybookStateMetadata(machineSnapshot);
431
+ const activeStateIds = active.map(({ stateId }) => stateId);
432
+ const tags = [...machineSnapshot.tags].sort();
433
+ const busy = tags.includes(BUSY_TAG);
434
+ const suspended = tags.includes(SUSPENDED_TAG);
435
+ const quiescent = machineSnapshot.status !== 'active' ||
436
+ (!busy && (!suspended || options.pendingCall !== undefined));
437
+ return {
438
+ value: normalizeStateValue(machineSnapshot.value),
439
+ activeStateIds,
440
+ tags,
441
+ status: machineSnapshot.status,
442
+ quiescent,
443
+ ...(activeStateIds.length === 1 ? { stateId: activeStateIds[0] } : {}),
444
+ };
445
+ }
446
+ // DR-014 §1: deep-detach an XState persisted actor snapshot into strict
447
+ // JSON for a PlaybookRuntimeSnapshot, normalizing any raw Error value
448
+ // (for example FSM context `lastError`) instead of rejecting it.
449
+ export function detachPersistedMachineSnapshot(persisted) {
450
+ return snapshotJsonValue(withErrorsNormalized(persisted, new Set()), 'persisted machine snapshot');
451
+ }
452
+ function withErrorsNormalized(value, ancestors) {
453
+ if (value instanceof Error)
454
+ return normalizeError(value);
455
+ if (Array.isArray(value)) {
456
+ if (ancestors.has(value))
457
+ return value;
458
+ const nextAncestors = new Set(ancestors).add(value);
459
+ return value.map((entry) => withErrorsNormalized(entry, nextAncestors));
460
+ }
461
+ if (isRecord(value)) {
462
+ if (ancestors.has(value))
463
+ return value;
464
+ const nextAncestors = new Set(ancestors).add(value);
465
+ const normalized = {};
466
+ for (const key of Object.keys(value)) {
467
+ // XState persisted snapshots carry `output: undefined` (and similar)
468
+ // on non-final states; JSON serialization drops those members, so the
469
+ // detached snapshot drops them too instead of rejecting.
470
+ if (value[key] === undefined)
471
+ continue;
472
+ defineEnumerableDataProperty(normalized, key, withErrorsNormalized(value[key], nextAncestors));
473
+ }
474
+ return normalized;
475
+ }
476
+ return value;
477
+ }
478
+ const SNAPSHOT_SEQUENCE_KEYS = [
479
+ 'trace',
480
+ 'turn',
481
+ 'judgeCall',
482
+ 'playerCall',
483
+ 'playbookCall',
484
+ ];
485
+ // DR-014 §1: validate and detach a host-supplied runtime snapshot before
486
+ // restore touches any state. Rejects a schema-version or playbook-id
487
+ // mismatch with a path-named error.
488
+ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
489
+ if (!isRecord(value)) {
490
+ throw new TypeError('runtime snapshot must be an object');
491
+ }
492
+ if (value.schemaVersion !== 1) {
493
+ throw new TypeError(`runtime snapshot schemaVersion ${String(value.schemaVersion)} is not supported (expected 1)`);
494
+ }
495
+ const playbookId = requireNonEmptyString(value.playbookId, 'runtime snapshot playbookId');
496
+ if (playbookId !== expectedPlaybookId) {
497
+ throw new TypeError(`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`);
498
+ }
499
+ if (!isRecord(value.machine)) {
500
+ throw new TypeError('runtime snapshot machine must be an object');
501
+ }
502
+ const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
503
+ if (!isRecord(value.playerResumeTokens)) {
504
+ throw new TypeError('runtime snapshot playerResumeTokens must be an object');
505
+ }
506
+ const playerResumeTokens = {};
507
+ for (const [playerId, token] of Object.entries(value.playerResumeTokens)) {
508
+ defineEnumerableDataProperty(playerResumeTokens, playerId, requireNonEmptyString(token, `runtime snapshot playerResumeTokens.${playerId}`));
509
+ }
510
+ if (!isRecord(value.sequences)) {
511
+ throw new TypeError('runtime snapshot sequences must be an object');
512
+ }
513
+ const sequences = {};
514
+ for (const key of SNAPSHOT_SEQUENCE_KEYS) {
515
+ const sequence = value.sequences[key];
516
+ if (!Number.isSafeInteger(sequence) || sequence < 0) {
517
+ throw new TypeError(`runtime snapshot sequences.${key} must be a non-negative integer`);
518
+ }
519
+ sequences[key] = sequence;
520
+ }
521
+ const captainCall = value.sequences.captainCall;
522
+ if (captainCall !== undefined) {
523
+ if (!Number.isSafeInteger(captainCall) || captainCall < 0) {
524
+ throw new TypeError('runtime snapshot sequences.captainCall must be a non-negative integer');
525
+ }
526
+ sequences.captainCall = captainCall;
527
+ }
528
+ validateState(value.state, 'runtime snapshot state');
529
+ const state = snapshotJsonValue(value.state, 'runtime snapshot state');
530
+ if (!Array.isArray(value.pendingBossQuestions)) {
531
+ throw new TypeError('runtime snapshot pendingBossQuestions must be an array');
532
+ }
533
+ const pendingBossQuestions = value.pendingBossQuestions.map((entry, index) => {
534
+ const path = `runtime snapshot pendingBossQuestions[${index}]`;
535
+ if (!isRecord(entry))
536
+ throw new TypeError(`${path} must be an object`);
537
+ const question = {
538
+ questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
539
+ player: requireNonEmptyString(entry.player, `${path}.player`),
540
+ question: requireNonEmptyString(entry.question, `${path}.question`),
541
+ ...(entry.sourceItem === undefined
542
+ ? {}
543
+ : {
544
+ sourceItem: requireNonEmptyString(entry.sourceItem, `${path}.sourceItem`),
545
+ }),
546
+ };
547
+ return Object.freeze(question);
548
+ });
549
+ return Object.freeze({
550
+ schemaVersion: 1,
551
+ playbookId,
552
+ machine,
553
+ playerResumeTokens: Object.freeze(playerResumeTokens),
554
+ sequences: Object.freeze(sequences),
555
+ state,
556
+ pendingBossQuestions: Object.freeze(pendingBossQuestions),
557
+ });
558
+ }
559
+ export class NestedPlaybookCallError extends Error {
560
+ result;
561
+ constructor(result) {
562
+ const fallback = `Child playbook ${result.playbookId} ${result.status}`;
563
+ const normalized = result.status === 'ok' ? undefined : result.error;
564
+ super(normalized?.message ?? fallback);
565
+ this.name = normalized?.name ?? 'NestedPlaybookCallError';
566
+ if (normalized?.stack)
567
+ this.stack = normalized.stack;
568
+ this.result = result;
569
+ }
570
+ }
571
+ function validateState(state, path) {
572
+ if (!isRecord(state))
573
+ throw new TypeError(`${path} must be an object`);
574
+ rejectUnknownKeys(state, ['value', 'activeStateIds', 'tags', 'status', 'quiescent', 'stateId'], path);
575
+ normalizeStateValue(state.value, `${path}.value`);
576
+ if (!Array.isArray(state.activeStateIds)) {
577
+ throw new TypeError(`${path}.activeStateIds must be an array`);
578
+ }
579
+ state.activeStateIds.forEach((value, index) => {
580
+ requireNonEmptyString(value, `${path}.activeStateIds[${index}]`);
581
+ });
582
+ if (new Set(state.activeStateIds).size !== state.activeStateIds.length) {
583
+ throw new TypeError(`${path}.activeStateIds must not contain duplicates`);
584
+ }
585
+ if (!Array.isArray(state.tags) ||
586
+ !state.tags.every((tag) => typeof tag === 'string' && tag.trim().length > 0)) {
587
+ throw new TypeError(`${path}.tags must be a non-empty string array`);
588
+ }
589
+ if (new Set(state.tags).size !== state.tags.length) {
590
+ throw new TypeError(`${path}.tags must not contain duplicates`);
591
+ }
592
+ if (state.status !== 'active' &&
593
+ state.status !== 'done' &&
594
+ state.status !== 'error' &&
595
+ state.status !== 'stopped') {
596
+ throw new TypeError(`${path}.status is invalid`);
597
+ }
598
+ if (typeof state.quiescent !== 'boolean') {
599
+ throw new TypeError(`${path}.quiescent must be boolean`);
600
+ }
601
+ if (own(state, 'stateId')) {
602
+ const stateId = requireNonEmptyString(state.stateId, `${path}.stateId`);
603
+ if (state.activeStateIds.length !== 1 ||
604
+ state.activeStateIds[0] !== stateId) {
605
+ throw new TypeError(`${path}.stateId must equal the sole active state id`);
606
+ }
607
+ }
608
+ }
609
+ function rejectUnknownKeys(value, allowed, path) {
610
+ const allowedKeys = new Set(allowed);
611
+ for (const key of Object.keys(value)) {
612
+ if (!allowedKeys.has(key)) {
613
+ throw new TypeError(`${path}.${key} is not a declared property`);
614
+ }
615
+ }
616
+ }
617
+ function validateRunStatus(status, path) {
618
+ if (status !== 'ok' && status !== 'aborted' && status !== 'error') {
619
+ throw new TypeError(`${path} is invalid`);
620
+ }
621
+ }
622
+ function validateOptionalString(value, key, path) {
623
+ if (own(value, key) && typeof value[key] !== 'string') {
624
+ throw new TypeError(`${path}.${key} must be a string`);
625
+ }
626
+ }
627
+ /** Validate, detach, and freeze a host direct-Captain result. */
628
+ export function validateCaptainResult(value, path = 'Captain result') {
629
+ const result = snapshotJsonValue(value, path);
630
+ if (!isRecord(result)) {
631
+ throw new TypeError(`${path} must be an object`);
632
+ }
633
+ rejectUnknownKeys(result, ['status', 'finalText', 'error'], path);
634
+ validateRunStatus(result.status, `${path}.status`);
635
+ validateOptionalString(result, 'finalText', path);
636
+ validateOptionalString(result, 'error', path);
637
+ return result;
638
+ }
639
+ /** Validate, detach, and freeze a host delegated-player result. */
640
+ export function validatePlayerResult(value, path = 'player result') {
641
+ const result = snapshotJsonValue(value, path);
642
+ if (!isRecord(result)) {
643
+ throw new TypeError(`${path} must be an object`);
644
+ }
645
+ rejectUnknownKeys(result, ['status', 'resumeToken', 'finalText', 'error'], path);
646
+ validateRunStatus(result.status, `${path}.status`);
647
+ validateOptionalString(result, 'resumeToken', path);
648
+ validateOptionalString(result, 'finalText', path);
649
+ validateOptionalString(result, 'error', path);
650
+ return result;
651
+ }
652
+ function validateNormalizedError(error, path) {
653
+ if (!isRecord(error)) {
654
+ throw new TypeError(`${path} must be a normalized error`);
655
+ }
656
+ rejectUnknownKeys(error, ['name', 'message', 'stack'], path);
657
+ requireNonEmptyString(error.name, `${path}.name`);
658
+ if (typeof error.message !== 'string') {
659
+ throw new TypeError(`${path}.message must be a string`);
660
+ }
661
+ if (error.stack !== undefined && typeof error.stack !== 'string') {
662
+ throw new TypeError(`${path}.stack must be a string`);
663
+ }
664
+ }
665
+ export function validatePlaybookCallResult(result, expectedPlaybookId, expectedChildSessionId) {
666
+ const capturedResult = snapshotJsonValue(result, 'playbook result');
667
+ if (!isRecord(capturedResult)) {
668
+ throw new TypeError('playbook result must be an object');
669
+ }
670
+ if (capturedResult.status !== 'ok' &&
671
+ capturedResult.status !== 'aborted' &&
672
+ capturedResult.status !== 'error') {
673
+ throw new TypeError('playbook result status is invalid');
674
+ }
675
+ if (capturedResult.playbookId !== expectedPlaybookId) {
676
+ throw new PlaybookCallIdentityError(`playbook result target ${String(capturedResult.playbookId)} does not match ${expectedPlaybookId}`);
677
+ }
678
+ if (capturedResult.status === 'ok') {
679
+ rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'output'], 'playbook result');
680
+ requireNonEmptyString(capturedResult.childSessionId, 'playbook result childSessionId');
681
+ }
682
+ else {
683
+ rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'error'], 'playbook result');
684
+ }
685
+ if (capturedResult.status !== 'ok' && own(capturedResult, 'childSessionId')) {
686
+ requireNonEmptyString(capturedResult.childSessionId, 'playbook result childSessionId');
687
+ }
688
+ if (expectedChildSessionId !== undefined &&
689
+ capturedResult.childSessionId !== expectedChildSessionId) {
690
+ throw new PlaybookCallIdentityError(`playbook result child session ${String(capturedResult.childSessionId)} does not match ${expectedChildSessionId}`);
691
+ }
692
+ if (own(capturedResult, 'state'))
693
+ validateState(capturedResult.state, 'playbook result state');
694
+ if (capturedResult.status === 'error' && !own(capturedResult, 'error')) {
695
+ throw new TypeError('playbook error result requires a normalized error');
696
+ }
697
+ if (capturedResult.status !== 'ok' && capturedResult.error !== undefined) {
698
+ validateNormalizedError(capturedResult.error, 'playbook result error');
699
+ }
700
+ return capturedResult;
701
+ }
702
+ class PlaybookCallIdentityError extends TypeError {
703
+ }
704
+ export function validatePlaybookCallStart(start, expectedPlaybookId) {
705
+ const capturedStart = snapshotJsonValue(start, 'playbook call start');
706
+ if (!isRecord(capturedStart)) {
707
+ throw new TypeError('playbook call start must be an object');
708
+ }
709
+ if (capturedStart.state === 'settled') {
710
+ rejectUnknownKeys(capturedStart, ['state', 'result'], 'playbook call start');
711
+ return Object.freeze({
712
+ state: 'settled',
713
+ result: validatePlaybookCallResult(capturedStart.result, expectedPlaybookId),
714
+ });
715
+ }
716
+ if (capturedStart.state === 'suspended') {
717
+ rejectUnknownKeys(capturedStart, ['state', 'childSessionId'], 'playbook call start');
718
+ const childSessionId = requireNonEmptyString(capturedStart.childSessionId, 'playbook call start childSessionId');
719
+ return Object.freeze({
720
+ state: 'suspended',
721
+ childSessionId,
722
+ });
723
+ }
724
+ throw new TypeError('playbook call start state is invalid');
725
+ }
726
+ function assignedChildSessionId(start) {
727
+ if (!isRecord(start))
728
+ return undefined;
729
+ try {
730
+ const startDescriptors = Object.getOwnPropertyDescriptors(start);
731
+ const state = capturedDataValue(startDescriptors, 'state', 'playbook call start state', false);
732
+ if (state === 'suspended') {
733
+ const childSessionId = capturedDataValue(startDescriptors, 'childSessionId', 'playbook call start childSessionId', false);
734
+ return typeof childSessionId === 'string' &&
735
+ childSessionId.trim().length > 0
736
+ ? childSessionId
737
+ : undefined;
738
+ }
739
+ if (state !== 'settled')
740
+ return undefined;
741
+ const result = capturedDataValue(startDescriptors, 'result', 'playbook call start result', false);
742
+ if (!isRecord(result))
743
+ return undefined;
744
+ const resultDescriptors = Object.getOwnPropertyDescriptors(result);
745
+ const childSessionId = capturedDataValue(resultDescriptors, 'childSessionId', 'playbook result childSessionId', false);
746
+ return typeof childSessionId === 'string' &&
747
+ childSessionId.trim().length > 0
748
+ ? childSessionId
749
+ : undefined;
750
+ }
751
+ catch {
752
+ // Cleanup identity is best effort for malformed or accessor-backed input.
753
+ return undefined;
754
+ }
755
+ }
756
+ function resultFromThrown(playbookId, childSessionId, error, aborted) {
757
+ const normalized = normalizeError(error);
758
+ const result = aborted
759
+ ? {
760
+ status: 'aborted',
761
+ playbookId,
762
+ ...(childSessionId ? { childSessionId } : {}),
763
+ error: normalized,
764
+ }
765
+ : {
766
+ status: 'error',
767
+ playbookId,
768
+ ...(childSessionId ? { childSessionId } : {}),
769
+ error: normalized,
770
+ };
771
+ return snapshotJsonValue(result, 'playbook result');
772
+ }
773
+ function outputOrThrow(result) {
774
+ if (result.status === 'ok')
775
+ return result.output;
776
+ throw new NestedPlaybookCallError(result);
777
+ }
778
+ export function createNestedPlaybookBridge(options) {
779
+ let current;
780
+ let disposed = false;
781
+ const usedCallIds = new Set();
782
+ const pendingListeners = new Set();
783
+ const reportBackgroundError = (error) => {
784
+ try {
785
+ options.onBackgroundError?.(error);
786
+ }
787
+ catch {
788
+ // Background observers are a terminal sink and cannot own cleanup.
789
+ }
790
+ };
791
+ const reportControlPlaneError = (error) => {
792
+ try {
793
+ options.onControlPlaneError?.(error);
794
+ }
795
+ catch (callbackError) {
796
+ // Observability callbacks must never prevent terminal cleanup of the
797
+ // invocation they are observing.
798
+ reportBackgroundError(callbackError);
799
+ }
800
+ };
801
+ const rejectControlPlane = (error) => {
802
+ reportControlPlaneError(error);
803
+ throw error;
804
+ };
805
+ const pendingIdentity = (active) => active?.phase === 'suspended' && active.childSessionId
806
+ ? {
807
+ callId: active.callId,
808
+ playbookId: active.input.playbookId,
809
+ childSessionId: active.childSessionId,
810
+ }
811
+ : undefined;
812
+ const clear = (active) => {
813
+ if (active.abortListener) {
814
+ active.signal.removeEventListener('abort', active.abortListener);
815
+ }
816
+ if (current === active)
817
+ current = undefined;
818
+ };
819
+ const emitFinish = async (active, result) => {
820
+ await options.emitFinished({
821
+ callId: active.callId,
822
+ stateId: active.input.stateId,
823
+ playbookId: active.input.playbookId,
824
+ text: active.input.text,
825
+ result,
826
+ });
827
+ await options.drain();
828
+ };
829
+ const finishImmediate = async (active, result, controlError, resultAfterAbortCleanup) => {
830
+ let effectiveResult = result;
831
+ let cleanupControlError;
832
+ if (result.status === 'aborted' || active.signal.aborted) {
833
+ try {
834
+ await drainPlaybookAbortCleanups(active.signal);
835
+ }
836
+ catch (error) {
837
+ cleanupControlError = error;
838
+ reportControlPlaneError(error);
839
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, error, false);
840
+ }
841
+ if (cleanupControlError === undefined && resultAfterAbortCleanup) {
842
+ effectiveResult = resultAfterAbortCleanup();
843
+ }
844
+ }
845
+ let finishControlError;
846
+ try {
847
+ await emitFinish(active, effectiveResult);
848
+ }
849
+ catch (error) {
850
+ reportControlPlaneError(error);
851
+ finishControlError = error;
852
+ }
853
+ finally {
854
+ // An immediate call can never be resumed. Even when its finish
855
+ // emission fails, do not leave a permanently unresumable call in the
856
+ // bridge and prevent disposal or a later invocation.
857
+ clear(active);
858
+ }
859
+ if (controlError !== undefined)
860
+ throw controlError;
861
+ if (cleanupControlError !== undefined)
862
+ throw cleanupControlError;
863
+ if (finishControlError !== undefined)
864
+ throw finishControlError;
865
+ return outputOrThrow(effectiveResult);
866
+ };
867
+ const settlePending = async (active, result, controlError) => {
868
+ if (active.phase === 'settling' && active.settlement) {
869
+ await active.settlement;
870
+ return;
871
+ }
872
+ if (active.phase !== 'suspended') {
873
+ throw new Error(`playbook call ${active.callId} is not suspended`);
874
+ }
875
+ active.phase = 'settling';
876
+ const settlement = (async () => {
877
+ let effectiveResult = result;
878
+ let cleanupControlError;
879
+ if (result.status === 'aborted' || active.signal.aborted) {
880
+ if (result.status !== 'aborted' && active.signal.aborted) {
881
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
882
+ new Error('Nested playbook invocation aborted'), true);
883
+ }
884
+ try {
885
+ await drainPlaybookAbortCleanups(active.signal);
886
+ }
887
+ catch (cleanupError) {
888
+ cleanupControlError = cleanupError;
889
+ reportControlPlaneError(cleanupError);
890
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, cleanupError, false);
891
+ }
892
+ }
893
+ try {
894
+ await emitFinish(active, effectiveResult);
895
+ }
896
+ catch (error) {
897
+ // A finish event is the durable return boundary. If it cannot be
898
+ // emitted and drained, the child result must not remain retryable:
899
+ // clear the identity and fail the promise actor so its parent takes
900
+ // onError instead of observing a phantom suspended child.
901
+ reportControlPlaneError(error);
902
+ clear(active);
903
+ active.deferred.reject(error);
904
+ throw error;
905
+ }
906
+ clear(active);
907
+ if (controlError !== undefined) {
908
+ active.deferred.reject(controlError);
909
+ }
910
+ else if (cleanupControlError !== undefined) {
911
+ active.deferred.reject(cleanupControlError);
912
+ }
913
+ else if (effectiveResult.status === 'ok') {
914
+ active.deferred.resolve(effectiveResult.output);
915
+ }
916
+ else {
917
+ active.deferred.reject(new NestedPlaybookCallError(effectiveResult));
918
+ }
919
+ if (cleanupControlError !== undefined)
920
+ throw cleanupControlError;
921
+ })();
922
+ active.settlement = settlement;
923
+ try {
924
+ await settlement;
925
+ }
926
+ catch (error) {
927
+ await active.finished.promise;
928
+ throw error;
929
+ }
930
+ };
931
+ const actorLogic = fromPromise(async ({ input, signal: invocationSignal }) => {
932
+ if (disposed) {
933
+ rejectControlPlane(new Error('nested playbook bridge is disposed'));
934
+ }
935
+ if (current) {
936
+ rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
937
+ }
938
+ const [normalizedInput, callId] = (() => {
939
+ try {
940
+ return [
941
+ {
942
+ stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
943
+ playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
944
+ text: requireNonEmptyString(input.text, 'playbook input text'),
945
+ },
946
+ requireNonEmptyString(options.nextCallId(), 'allocated playbook call id'),
947
+ ];
948
+ }
949
+ catch (error) {
950
+ return rejectControlPlane(error);
951
+ }
952
+ })();
953
+ if (usedCallIds.has(callId)) {
954
+ rejectControlPlane(new Error(`allocated duplicate playbook call id ${callId}`));
955
+ }
956
+ usedCallIds.add(callId);
957
+ const controller = new AbortController();
958
+ let callSignal;
959
+ try {
960
+ callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
961
+ }
962
+ catch (error) {
963
+ return rejectControlPlane(error);
964
+ }
965
+ const active = {
966
+ callId,
967
+ input: normalizedInput,
968
+ deferred: deferred(),
969
+ finished: deferred(),
970
+ controller,
971
+ signal: callSignal,
972
+ phase: 'starting',
973
+ };
974
+ current = active;
975
+ try {
976
+ // Promise actors may begin before XState publishes the root snapshot
977
+ // for their entering state. Yield through the runtime's global queue so
978
+ // that transition/status telemetry is enqueued before call.started.
979
+ try {
980
+ await options.drain();
981
+ }
982
+ catch (error) {
983
+ reportControlPlaneError(error);
984
+ clear(active);
985
+ throw error;
986
+ }
987
+ try {
988
+ await options.emitStarted({ callId, ...normalizedInput });
989
+ }
990
+ catch (error) {
991
+ reportControlPlaneError(error);
992
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, false), error);
993
+ }
994
+ try {
995
+ await options.drain();
996
+ }
997
+ catch (error) {
998
+ reportControlPlaneError(error);
999
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, false), error);
1000
+ }
1001
+ const request = {
1002
+ callId,
1003
+ playbookId: normalizedInput.playbookId,
1004
+ text: normalizedInput.text,
1005
+ };
1006
+ let rawStart;
1007
+ let observedStart;
1008
+ let startSettled = false;
1009
+ let removeOpeningAbortListener = () => undefined;
1010
+ try {
1011
+ if (active.signal.aborted)
1012
+ throw active.signal.reason;
1013
+ const starting = options.callPlaybook(request, active.signal).then((value) => {
1014
+ observedStart = value;
1015
+ startSettled = true;
1016
+ return value;
1017
+ }, (error) => {
1018
+ startSettled = true;
1019
+ throw error;
1020
+ });
1021
+ const openingCleanup = starting.then(() => undefined, (error) => {
1022
+ if (isAbortReason(error, active.signal))
1023
+ return;
1024
+ throw error;
1025
+ });
1026
+ void openingCleanup.catch(() => undefined);
1027
+ const registerOpeningCleanup = () => registerPlaybookAbortCleanup(active.signal, openingCleanup);
1028
+ removeOpeningAbortListener = () => active.signal.removeEventListener('abort', registerOpeningCleanup);
1029
+ if (active.signal.aborted)
1030
+ registerOpeningCleanup();
1031
+ else {
1032
+ active.signal.addEventListener('abort', registerOpeningCleanup, {
1033
+ once: true,
1034
+ });
1035
+ }
1036
+ rawStart = await withAbort(starting, active.signal);
1037
+ }
1038
+ catch (error) {
1039
+ const controlError = active.signal.aborted ? undefined : error;
1040
+ if (controlError !== undefined)
1041
+ reportControlPlaneError(controlError);
1042
+ const result = resultFromThrown(normalizedInput.playbookId, undefined, error, active.signal.aborted);
1043
+ return await finishImmediate(active, result, controlError, active.signal.aborted
1044
+ ? () => resultFromThrown(normalizedInput.playbookId, startSettled
1045
+ ? assignedChildSessionId(observedStart)
1046
+ : undefined, error, true)
1047
+ : undefined);
1048
+ }
1049
+ finally {
1050
+ removeOpeningAbortListener();
1051
+ }
1052
+ let start;
1053
+ try {
1054
+ start = validatePlaybookCallStart(rawStart, normalizedInput.playbookId);
1055
+ }
1056
+ catch (error) {
1057
+ reportControlPlaneError(error);
1058
+ const childSessionId = assignedChildSessionId(rawStart);
1059
+ active.childSessionId = childSessionId;
1060
+ if (isRecord(rawStart) &&
1061
+ rawStart.state === 'suspended' &&
1062
+ !active.controller.signal.aborted) {
1063
+ // The host may already have opened a child before returning this
1064
+ // malformed suspended start. Abort the same signal it received so
1065
+ // its registered child cleanup drains before the parent finish.
1066
+ active.controller.abort(error);
1067
+ }
1068
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, childSessionId, error, false), error);
1069
+ }
1070
+ if (active.signal.aborted) {
1071
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, start.state === 'suspended' ? start.childSessionId : undefined, active.signal.reason, true));
1072
+ }
1073
+ if (start.state === 'settled') {
1074
+ return await finishImmediate(active, start.result);
1075
+ }
1076
+ active.phase = 'suspended';
1077
+ active.childSessionId = start.childSessionId;
1078
+ const abortListener = () => {
1079
+ if (active.phase !== 'suspended')
1080
+ return;
1081
+ const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
1082
+ new Error('Nested playbook invocation aborted'), true);
1083
+ void settlePending(active, result).catch((error) => {
1084
+ reportBackgroundError(error);
1085
+ });
1086
+ };
1087
+ active.abortListener = abortListener;
1088
+ active.signal.addEventListener('abort', abortListener, { once: true });
1089
+ const pendingCall = pendingIdentity(active);
1090
+ if (!pendingCall) {
1091
+ throw new Error('suspended call identity was not recorded');
1092
+ }
1093
+ for (const listener of pendingListeners) {
1094
+ try {
1095
+ listener(pendingCall);
1096
+ }
1097
+ catch (error) {
1098
+ reportBackgroundError(error);
1099
+ }
1100
+ }
1101
+ if (active.signal.aborted)
1102
+ abortListener();
1103
+ return await active.deferred.promise;
1104
+ }
1105
+ catch (error) {
1106
+ active.runError = error;
1107
+ throw error;
1108
+ }
1109
+ finally {
1110
+ active.finished.resolve(undefined);
1111
+ }
1112
+ });
1113
+ const abortPending = async (error = new Error('Nested playbook call aborted')) => {
1114
+ const active = current;
1115
+ if (!active)
1116
+ return;
1117
+ if (!active.controller.signal.aborted)
1118
+ active.controller.abort(error);
1119
+ if (active.phase === 'starting') {
1120
+ await active.finished.promise;
1121
+ if (active.runError !== undefined &&
1122
+ !(active.runError instanceof NestedPlaybookCallError)) {
1123
+ throw active.runError;
1124
+ }
1125
+ return;
1126
+ }
1127
+ if (active.phase === 'settling' && active.settlement) {
1128
+ await active.settlement;
1129
+ await active.finished.promise;
1130
+ return;
1131
+ }
1132
+ const pendingCall = pendingIdentity(active);
1133
+ if (!pendingCall)
1134
+ return;
1135
+ await settlePending(active, resultFromThrown(pendingCall.playbookId, pendingCall.childSessionId, error, true));
1136
+ await active.finished.promise;
1137
+ };
1138
+ return {
1139
+ actorLogic,
1140
+ getPendingCall: () => pendingIdentity(current),
1141
+ subscribePendingCall(listener) {
1142
+ if (disposed)
1143
+ return () => undefined;
1144
+ pendingListeners.add(listener);
1145
+ const pendingCall = pendingIdentity(current);
1146
+ if (pendingCall) {
1147
+ try {
1148
+ listener(pendingCall);
1149
+ }
1150
+ catch (error) {
1151
+ reportBackgroundError(error);
1152
+ }
1153
+ }
1154
+ return () => pendingListeners.delete(listener);
1155
+ },
1156
+ async resume({ callId, result, signal }) {
1157
+ const active = current;
1158
+ const pendingCall = pendingIdentity(active);
1159
+ if (!active || !pendingCall) {
1160
+ throw new Error(`unknown or stale playbook call id ${callId}`);
1161
+ }
1162
+ if (callId !== pendingCall.callId) {
1163
+ const error = new PlaybookCallIdentityError(`playbook call id ${callId} does not match ${pendingCall.callId}`);
1164
+ reportControlPlaneError(error);
1165
+ throw error;
1166
+ }
1167
+ let validatedResult;
1168
+ try {
1169
+ validatedResult = validatePlaybookCallResult(result, pendingCall.playbookId, pendingCall.childSessionId);
1170
+ }
1171
+ catch (error) {
1172
+ reportControlPlaneError(error);
1173
+ if (!(error instanceof PlaybookCallIdentityError)) {
1174
+ await settlePending(active, resultFromThrown(pendingCall.playbookId, pendingCall.childSessionId, error, false), error);
1175
+ await active.finished.promise;
1176
+ }
1177
+ throw error;
1178
+ }
1179
+ options.bindResumeSignal?.(signal);
1180
+ await settlePending(active, validatedResult);
1181
+ },
1182
+ abortPending,
1183
+ async dispose() {
1184
+ if (disposed)
1185
+ return;
1186
+ disposed = true;
1187
+ try {
1188
+ const active = current;
1189
+ await abortPending(new Error('Nested playbook bridge disposed'));
1190
+ if (active?.runError !== undefined &&
1191
+ !(active.runError instanceof NestedPlaybookCallError)) {
1192
+ throw active.runError;
1193
+ }
1194
+ }
1195
+ finally {
1196
+ pendingListeners.clear();
1197
+ }
1198
+ },
1199
+ };
1200
+ }
1201
+ /**
1202
+ * Wait at the imperative runtime boundary; workflow waiting remains in XState.
1203
+ * A pending-call notification covers the case where the suspended state was
1204
+ * entered before its host returned the child session id.
1205
+ */
1206
+ export async function waitForPlaybookQuiescence(actor, options = {}) {
1207
+ const current = actor.getSnapshot();
1208
+ const normalized = normalizePlaybookSnapshot(current, {
1209
+ pendingCall: options.pendingCalls?.getPendingCall(),
1210
+ });
1211
+ if (normalized.quiescent)
1212
+ return current;
1213
+ const waitOptions = {
1214
+ ...(options.timeout === undefined ? {} : { timeout: options.timeout }),
1215
+ };
1216
+ const waitController = options.pendingCalls
1217
+ ? new AbortController()
1218
+ : undefined;
1219
+ const forwardAbort = () => waitController?.abort(options.signal?.reason);
1220
+ if (waitController && options.signal) {
1221
+ if (options.signal.aborted)
1222
+ forwardAbort();
1223
+ else
1224
+ options.signal.addEventListener('abort', forwardAbort, { once: true });
1225
+ }
1226
+ const snapshotWait = waitFor(actor, (snapshot) => normalizePlaybookSnapshot(snapshot, {
1227
+ pendingCall: options.pendingCalls?.getPendingCall(),
1228
+ }).quiescent, {
1229
+ ...waitOptions,
1230
+ ...(waitController
1231
+ ? { signal: waitController.signal }
1232
+ : options.signal
1233
+ ? { signal: options.signal }
1234
+ : {}),
1235
+ });
1236
+ if (!options.pendingCalls)
1237
+ return snapshotWait;
1238
+ let unsubscribe = () => undefined;
1239
+ const pendingWait = new Promise((resolve) => {
1240
+ unsubscribe =
1241
+ options.pendingCalls?.subscribePendingCall(() => {
1242
+ const snapshot = actor.getSnapshot();
1243
+ if (normalizePlaybookSnapshot(snapshot, {
1244
+ pendingCall: options.pendingCalls?.getPendingCall(),
1245
+ }).quiescent) {
1246
+ resolve(snapshot);
1247
+ }
1248
+ }) ?? unsubscribe;
1249
+ });
1250
+ try {
1251
+ return await Promise.race([snapshotWait, pendingWait]);
1252
+ }
1253
+ finally {
1254
+ unsubscribe();
1255
+ waitController?.abort(new Error('Playbook quiescence already settled'));
1256
+ options.signal?.removeEventListener('abort', forwardAbort);
1257
+ }
1258
+ }