@sublang/playbook 7.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/docs/cli.md +88 -43
- package/docs/configuration.md +221 -119
- package/docs/embedding.md +78 -27
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
- package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +18 -15
- package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +25 -15
- package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
- package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
- package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +29 -23
- package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +45 -24
- package/slc/link.md +297 -135
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +21 -16
- package/src/runtime.ts +20 -23
- package/src/xstate-playbook-runtime.d.ts +34 -20
- package/src/xstate-playbook-runtime.js +973 -400
- package/src/xstate-playbook-runtime.ts +1203 -457
- package/src/xstate-runtime.d.ts +17 -7
- package/src/xstate-runtime.js +198 -81
- package/src/xstate-runtime.ts +339 -112
package/src/xstate-runtime.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { type AnyActorRef, type PromiseActorLogic, type SnapshotFrom } from 'xstate';
|
|
2
2
|
import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookSuspendedCall, PlayerResult } from './runtime.js';
|
|
3
3
|
export * from './xstate-playbook-runtime.js';
|
|
4
|
+
/**
|
|
5
|
+
* Immutable cancellation provenance for one runtime operation. The captured
|
|
6
|
+
* signal identities do not change when a mutable runtime advances to another
|
|
7
|
+
* public boundary, while each signal's eventual reason remains observable.
|
|
8
|
+
*/
|
|
9
|
+
interface AbortReasonClassifier {
|
|
10
|
+
isAbortReason(error: unknown): boolean;
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
13
|
* Compose invocation-lifetime and imperative-boundary cancellation without
|
|
6
14
|
* installing a second forwarding listener in each generated runtime.
|
|
@@ -22,7 +30,7 @@ export declare function hiddenControlEnvelope(prompt: string): string;
|
|
|
22
30
|
export declare function normalizeError(error: unknown): NormalizedError;
|
|
23
31
|
export interface PlaybookStateMetadata {
|
|
24
32
|
stateId: string;
|
|
25
|
-
description
|
|
33
|
+
description?: string;
|
|
26
34
|
}
|
|
27
35
|
/** Read stable state identity without consulting XState's private `_nodes`. */
|
|
28
36
|
export declare function activePlaybookStateMetadata(snapshot: unknown): readonly PlaybookStateMetadata[];
|
|
@@ -59,12 +67,14 @@ export interface NestedPlaybookBridgeOptions {
|
|
|
59
67
|
/** Active public runtime boundary whose abort also owns a new child call. */
|
|
60
68
|
getBoundarySignal?(): AbortSignal | undefined;
|
|
61
69
|
callPlaybook(request: PlaybookCallRequest, signal: AbortSignal): Promise<PlaybookCallStart>;
|
|
62
|
-
emitStarted(event: PlaybookCallStarted): Promise<void>;
|
|
63
|
-
emitFinished(event: PlaybookCallFinished): Promise<void>;
|
|
64
|
-
drain(): Promise<void>;
|
|
65
|
-
bindResumeSignal?(signal: AbortSignal): void;
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
emitStarted(event: PlaybookCallStarted, aborts?: AbortReasonClassifier): Promise<void>;
|
|
71
|
+
emitFinished(event: PlaybookCallFinished, aborts?: AbortReasonClassifier): Promise<void>;
|
|
72
|
+
drain(aborts?: AbortReasonClassifier): Promise<void>;
|
|
73
|
+
bindResumeSignal?(signal: AbortSignal, aborts?: AbortReasonClassifier): void;
|
|
74
|
+
/** Bind provenance to the root transition caused by this child result. */
|
|
75
|
+
bindActorSettlement?(aborts: AbortReasonClassifier): void;
|
|
76
|
+
onControlPlaneError?(error: unknown, aborts?: AbortReasonClassifier): void;
|
|
77
|
+
onBackgroundError?(error: unknown, aborts?: AbortReasonClassifier): void;
|
|
68
78
|
}
|
|
69
79
|
export declare class NestedPlaybookCallError extends Error {
|
|
70
80
|
readonly result: PlaybookCallResult;
|
package/src/xstate-runtime.js
CHANGED
|
@@ -31,9 +31,19 @@ function withAbort(promise, signal) {
|
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
+
// slc/link.md §Abort: cancellation is causal identity with the applicable
|
|
35
|
+
// signal's reason; an `AbortError`-named rejection that is not that exact
|
|
36
|
+
// reason is a control-plane failure to surface, never an abort to swallow.
|
|
34
37
|
function isAbortReason(error, signal) {
|
|
35
|
-
return
|
|
36
|
-
|
|
38
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
39
|
+
}
|
|
40
|
+
function createAbortReasonClassifier(...sources) {
|
|
41
|
+
const captured = Object.freeze(sources.filter((source) => source !== undefined));
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
|
|
44
|
+
? isAbortReason(error, source)
|
|
45
|
+
: source.isAbortReason(error)),
|
|
46
|
+
});
|
|
37
47
|
}
|
|
38
48
|
const NEVER_ABORTED_SIGNAL = new AbortController().signal;
|
|
39
49
|
/**
|
|
@@ -73,7 +83,7 @@ export function registerPlaybookAbortCleanup(signal, cleanup) {
|
|
|
73
83
|
// the bridge's allSettled drain observes its outcome.
|
|
74
84
|
void cleanup.catch(() => undefined);
|
|
75
85
|
}
|
|
76
|
-
async function drainPlaybookAbortCleanups(signal) {
|
|
86
|
+
async function drainPlaybookAbortCleanups(signal, aborts) {
|
|
77
87
|
const failures = [];
|
|
78
88
|
while (true) {
|
|
79
89
|
const pending = abortCleanups.get(signal);
|
|
@@ -83,8 +93,10 @@ async function drainPlaybookAbortCleanups(signal) {
|
|
|
83
93
|
pending.clear();
|
|
84
94
|
const outcomes = await Promise.allSettled(batch);
|
|
85
95
|
for (const outcome of outcomes) {
|
|
86
|
-
if (outcome.status === 'rejected'
|
|
96
|
+
if (outcome.status === 'rejected' &&
|
|
97
|
+
!aborts.isAbortReason(outcome.reason)) {
|
|
87
98
|
failures.push(outcome.reason);
|
|
99
|
+
}
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
abortCleanups.delete(signal);
|
|
@@ -268,6 +280,25 @@ function capturedSessionStore(descriptors) {
|
|
|
268
280
|
restore: method('restore'),
|
|
269
281
|
});
|
|
270
282
|
}
|
|
283
|
+
function capturedRoleBindings(descriptors) {
|
|
284
|
+
const captured = snapshotJsonValue(capturedDataValue(descriptors, 'roleBindings', 'playbook session roleBindings'), 'playbook session roleBindings');
|
|
285
|
+
if (!isRecord(captured)) {
|
|
286
|
+
throw new TypeError('playbook session roleBindings must be an object');
|
|
287
|
+
}
|
|
288
|
+
const bindings = {};
|
|
289
|
+
for (const [roleId, value] of Object.entries(captured)) {
|
|
290
|
+
requireNonEmptyString(roleId, 'playbook session roleBindings role id');
|
|
291
|
+
if (!isRecord(value)) {
|
|
292
|
+
throw new TypeError(`playbook session roleBindings.${roleId} must be an object`);
|
|
293
|
+
}
|
|
294
|
+
rejectUnknownKeys(value, ['playerId', 'promptIdentity'], `playbook session roleBindings.${roleId}`);
|
|
295
|
+
defineEnumerableDataProperty(bindings, roleId, Object.freeze({
|
|
296
|
+
playerId: requireNonEmptyString(value.playerId, `playbook session roleBindings.${roleId}.playerId`),
|
|
297
|
+
promptIdentity: requireNonEmptyString(value.promptIdentity, `playbook session roleBindings.${roleId}.promptIdentity`),
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
return Object.freeze(bindings);
|
|
301
|
+
}
|
|
271
302
|
/** Validate session causality and detach its immutable identity from the host. */
|
|
272
303
|
export function snapshotPlaybookSession(session) {
|
|
273
304
|
if (!isRecord(session)) {
|
|
@@ -287,6 +318,7 @@ export function snapshotPlaybookSession(session) {
|
|
|
287
318
|
const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
|
|
288
319
|
const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
|
|
289
320
|
const hasPlayerSessions = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'playerSessions');
|
|
321
|
+
const hasRoleBindings = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'roleBindings');
|
|
290
322
|
let parentSessionId;
|
|
291
323
|
let parentCallId;
|
|
292
324
|
if (depth === 0) {
|
|
@@ -320,6 +352,9 @@ export function snapshotPlaybookSession(session) {
|
|
|
320
352
|
const playerSessions = hasPlayerSessions
|
|
321
353
|
? capturedSessionStore(sessionDescriptors)
|
|
322
354
|
: undefined;
|
|
355
|
+
const roleBindings = hasRoleBindings
|
|
356
|
+
? capturedRoleBindings(sessionDescriptors)
|
|
357
|
+
: undefined;
|
|
323
358
|
return Object.freeze({
|
|
324
359
|
sessionId,
|
|
325
360
|
playbookId,
|
|
@@ -327,6 +362,7 @@ export function snapshotPlaybookSession(session) {
|
|
|
327
362
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
328
363
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
329
364
|
depth,
|
|
365
|
+
...(roleBindings === undefined ? {} : { roleBindings }),
|
|
330
366
|
...(playerSessions === undefined ? {} : { playerSessions }),
|
|
331
367
|
ports,
|
|
332
368
|
});
|
|
@@ -460,12 +496,23 @@ export function activePlaybookStateMetadata(snapshot) {
|
|
|
460
496
|
throw new TypeError(`${nodeId}.meta.playbook must be an object`);
|
|
461
497
|
}
|
|
462
498
|
const stateId = requireNonEmptyString(meta.playbook.stateId, `${nodeId}.meta.playbook.stateId`);
|
|
463
|
-
|
|
499
|
+
// Description is optional: a state may declare none and stay fully
|
|
500
|
+
// usable, merely carrying no `stateDescription` downstream. A declared
|
|
501
|
+
// description must still be a nonempty string.
|
|
502
|
+
const description = meta.playbook.description === undefined
|
|
503
|
+
? undefined
|
|
504
|
+
: requireNonEmptyString(meta.playbook.description, `${nodeId}.meta.playbook.description`);
|
|
464
505
|
const previous = byStateId.get(stateId);
|
|
465
|
-
if (previous
|
|
506
|
+
if (previous?.description !== undefined &&
|
|
507
|
+
description !== undefined &&
|
|
508
|
+
previous.description !== description) {
|
|
466
509
|
throw new TypeError(`active state id ${stateId} has conflicting descriptions`);
|
|
467
510
|
}
|
|
468
|
-
|
|
511
|
+
const effective = description ?? previous?.description;
|
|
512
|
+
byStateId.set(stateId, {
|
|
513
|
+
stateId,
|
|
514
|
+
...(effective === undefined ? {} : { description: effective }),
|
|
515
|
+
});
|
|
469
516
|
}
|
|
470
517
|
return [...byStateId.values()].sort((left, right) => left.stateId.localeCompare(right.stateId));
|
|
471
518
|
}
|
|
@@ -548,9 +595,10 @@ function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
|
|
|
548
595
|
}
|
|
549
596
|
return Object.freeze(call);
|
|
550
597
|
}
|
|
551
|
-
// DR-014 §1 / DR-031 §5: validate and detach a host-supplied
|
|
552
|
-
// snapshot before restore touches any state. A suspended
|
|
553
|
-
// rejected unless the restore path explicitly promises to seed and
|
|
598
|
+
// DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
|
|
599
|
+
// schema-3 runtime snapshot before restore touches any state. A suspended
|
|
600
|
+
// call is rejected unless the restore path explicitly promises to seed and
|
|
601
|
+
// claim it; older schemas are rejected rather than guessing role identity.
|
|
554
602
|
export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options = {}) {
|
|
555
603
|
const snapshot = snapshotJsonValue(value, 'runtime snapshot');
|
|
556
604
|
if (!isRecord(snapshot)) {
|
|
@@ -566,25 +614,21 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
|
|
|
566
614
|
throw new TypeError('runtime snapshot validation options.allowSuspendedCall must be boolean');
|
|
567
615
|
}
|
|
568
616
|
const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
|
|
569
|
-
if (snapshot.schemaVersion !==
|
|
570
|
-
throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected
|
|
617
|
+
if (snapshot.schemaVersion !== 3) {
|
|
618
|
+
throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
|
|
571
619
|
}
|
|
572
|
-
const schemaVersion = snapshot.schemaVersion;
|
|
573
620
|
rejectUnknownKeys(snapshot, [
|
|
574
621
|
'schemaVersion',
|
|
575
622
|
'playbookId',
|
|
576
623
|
'machine',
|
|
577
|
-
'
|
|
624
|
+
'roleResumeTokens',
|
|
578
625
|
'sequences',
|
|
579
626
|
'state',
|
|
580
627
|
'pendingBossQuestions',
|
|
581
628
|
'suspendedCall',
|
|
582
629
|
], 'runtime snapshot');
|
|
583
|
-
if (schemaVersion === 1 && own(snapshot, 'suspendedCall')) {
|
|
584
|
-
throw new TypeError('runtime snapshot schemaVersion 1 must not carry suspendedCall');
|
|
585
|
-
}
|
|
586
630
|
let suspendedCall;
|
|
587
|
-
if (
|
|
631
|
+
if (own(snapshot, 'suspendedCall')) {
|
|
588
632
|
suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
|
|
589
633
|
if (!allowSuspendedCall) {
|
|
590
634
|
throw new TypeError('runtime snapshot suspendedCall requires a restore path that explicitly allows it');
|
|
@@ -598,12 +642,12 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
|
|
|
598
642
|
throw new TypeError('runtime snapshot machine must be an object');
|
|
599
643
|
}
|
|
600
644
|
const machine = snapshot.machine;
|
|
601
|
-
if (!isRecord(snapshot.
|
|
602
|
-
throw new TypeError('runtime snapshot
|
|
645
|
+
if (!isRecord(snapshot.roleResumeTokens)) {
|
|
646
|
+
throw new TypeError('runtime snapshot roleResumeTokens must be an object');
|
|
603
647
|
}
|
|
604
|
-
const
|
|
605
|
-
for (const [
|
|
606
|
-
defineEnumerableDataProperty(
|
|
648
|
+
const roleResumeTokens = {};
|
|
649
|
+
for (const [roleId, token] of Object.entries(snapshot.roleResumeTokens)) {
|
|
650
|
+
defineEnumerableDataProperty(roleResumeTokens, requireNonEmptyString(roleId, 'runtime snapshot roleResumeTokens role id'), requireNonEmptyString(token, `runtime snapshot roleResumeTokens.${roleId}`));
|
|
607
651
|
}
|
|
608
652
|
if (!isRecord(snapshot.sequences)) {
|
|
609
653
|
throw new TypeError('runtime snapshot sequences must be an object');
|
|
@@ -627,7 +671,7 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
|
|
|
627
671
|
validateState(snapshot.state, 'runtime snapshot state');
|
|
628
672
|
const state = snapshot.state;
|
|
629
673
|
if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
|
|
630
|
-
throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires
|
|
674
|
+
throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires suspendedCall`);
|
|
631
675
|
}
|
|
632
676
|
if (suspendedCall) {
|
|
633
677
|
if (sequences.playbookCall === 0) {
|
|
@@ -654,10 +698,28 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
|
|
|
654
698
|
const path = `runtime snapshot pendingBossQuestions[${index}]`;
|
|
655
699
|
if (!isRecord(entry))
|
|
656
700
|
throw new TypeError(`${path} must be an object`);
|
|
657
|
-
rejectUnknownKeys(entry, ['questionId', '
|
|
701
|
+
rejectUnknownKeys(entry, ['questionId', 'asker', 'question', 'sourceItem'], path);
|
|
702
|
+
if (!isRecord(entry.asker)) {
|
|
703
|
+
throw new TypeError(`${path}.asker must be an object`);
|
|
704
|
+
}
|
|
705
|
+
let asker;
|
|
706
|
+
if (entry.asker.kind === 'captain') {
|
|
707
|
+
rejectUnknownKeys(entry.asker, ['kind'], `${path}.asker`);
|
|
708
|
+
asker = Object.freeze({ kind: 'captain' });
|
|
709
|
+
}
|
|
710
|
+
else if (entry.asker.kind === 'role') {
|
|
711
|
+
rejectUnknownKeys(entry.asker, ['kind', 'roleId'], `${path}.asker`);
|
|
712
|
+
asker = Object.freeze({
|
|
713
|
+
kind: 'role',
|
|
714
|
+
roleId: requireNonEmptyString(entry.asker.roleId, `${path}.asker.roleId`),
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
throw new TypeError(`${path}.asker.kind must be "captain" or "role"`);
|
|
719
|
+
}
|
|
658
720
|
const question = {
|
|
659
721
|
questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
|
|
660
|
-
|
|
722
|
+
asker,
|
|
661
723
|
question: requireNonEmptyString(entry.question, `${path}.question`),
|
|
662
724
|
...(entry.sourceItem === undefined
|
|
663
725
|
? {}
|
|
@@ -670,16 +732,13 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
|
|
|
670
732
|
const fields = {
|
|
671
733
|
playbookId,
|
|
672
734
|
machine,
|
|
673
|
-
|
|
735
|
+
roleResumeTokens: Object.freeze(roleResumeTokens),
|
|
674
736
|
sequences: Object.freeze(sequences),
|
|
675
737
|
state,
|
|
676
738
|
pendingBossQuestions: Object.freeze(pendingBossQuestions),
|
|
677
739
|
};
|
|
678
|
-
if (schemaVersion === 1) {
|
|
679
|
-
return Object.freeze({ schemaVersion: 1, ...fields });
|
|
680
|
-
}
|
|
681
740
|
return Object.freeze({
|
|
682
|
-
schemaVersion:
|
|
741
|
+
schemaVersion: 3,
|
|
683
742
|
...fields,
|
|
684
743
|
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
685
744
|
});
|
|
@@ -773,6 +832,9 @@ export function validatePlayerResult(value, path = 'player result') {
|
|
|
773
832
|
rejectUnknownKeys(result, ['status', 'resumeToken', 'finalText', 'error'], path);
|
|
774
833
|
validateRunStatus(result.status, `${path}.status`);
|
|
775
834
|
validateOptionalString(result, 'resumeToken', path);
|
|
835
|
+
if (result.resumeToken !== undefined) {
|
|
836
|
+
requireNonEmptyString(result.resumeToken, `${path}.resumeToken`);
|
|
837
|
+
}
|
|
776
838
|
validateOptionalString(result, 'finalText', path);
|
|
777
839
|
validateOptionalString(result, 'error', path);
|
|
778
840
|
return result;
|
|
@@ -909,22 +971,26 @@ export function createNestedPlaybookBridge(options) {
|
|
|
909
971
|
let disposed = false;
|
|
910
972
|
const usedCallIds = new Set();
|
|
911
973
|
const pendingListeners = new Set();
|
|
912
|
-
const reportBackgroundError = (error) => {
|
|
974
|
+
const reportBackgroundError = (error, aborts) => {
|
|
975
|
+
if (aborts?.isAbortReason(error))
|
|
976
|
+
return;
|
|
913
977
|
try {
|
|
914
|
-
options.onBackgroundError?.(error);
|
|
978
|
+
options.onBackgroundError?.(error, aborts);
|
|
915
979
|
}
|
|
916
980
|
catch {
|
|
917
981
|
// Background observers are a terminal sink and cannot own cleanup.
|
|
918
982
|
}
|
|
919
983
|
};
|
|
920
|
-
const reportControlPlaneError = (error) => {
|
|
984
|
+
const reportControlPlaneError = (error, aborts) => {
|
|
985
|
+
if (aborts?.isAbortReason(error))
|
|
986
|
+
return;
|
|
921
987
|
try {
|
|
922
|
-
options.onControlPlaneError?.(error);
|
|
988
|
+
options.onControlPlaneError?.(error, aborts);
|
|
923
989
|
}
|
|
924
990
|
catch (callbackError) {
|
|
925
991
|
// Observability callbacks must never prevent terminal cleanup of the
|
|
926
992
|
// invocation they are observing.
|
|
927
|
-
reportBackgroundError(callbackError);
|
|
993
|
+
reportBackgroundError(callbackError, aborts);
|
|
928
994
|
}
|
|
929
995
|
};
|
|
930
996
|
const rejectControlPlane = (error) => {
|
|
@@ -964,27 +1030,37 @@ export function createNestedPlaybookBridge(options) {
|
|
|
964
1030
|
if (current === active)
|
|
965
1031
|
current = undefined;
|
|
966
1032
|
};
|
|
967
|
-
|
|
1033
|
+
// A failure causally identical to an applicable abort reason is the
|
|
1034
|
+
// cancellation's own evidence, never a control-plane error.
|
|
1035
|
+
const reportNonAbortControlError = (error, aborts) => {
|
|
1036
|
+
reportControlPlaneError(error, aborts);
|
|
1037
|
+
};
|
|
1038
|
+
const emitFinish = async (active, result, aborts) => {
|
|
968
1039
|
await options.emitFinished({
|
|
969
1040
|
callId: active.callId,
|
|
970
1041
|
stateId: active.input.stateId,
|
|
971
1042
|
playbookId: active.input.playbookId,
|
|
972
1043
|
text: active.input.text,
|
|
973
1044
|
result,
|
|
974
|
-
});
|
|
975
|
-
await options.drain();
|
|
1045
|
+
}, aborts);
|
|
1046
|
+
await options.drain(aborts);
|
|
976
1047
|
};
|
|
977
1048
|
const finishImmediate = async (active, result, controlError, resultAfterAbortCleanup) => {
|
|
1049
|
+
const aborts = active.aborts;
|
|
978
1050
|
let effectiveResult = result;
|
|
979
1051
|
let cleanupControlError;
|
|
980
1052
|
if (result.status === 'aborted' || active.signal.aborted) {
|
|
981
1053
|
try {
|
|
982
|
-
await drainPlaybookAbortCleanups(active.signal);
|
|
1054
|
+
await drainPlaybookAbortCleanups(active.signal, aborts);
|
|
983
1055
|
}
|
|
984
1056
|
catch (error) {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1057
|
+
// A cleanup rejection identical to an applicable abort reason is
|
|
1058
|
+
// the cancellation's own evidence — no latch, no result override.
|
|
1059
|
+
if (!aborts.isAbortReason(error)) {
|
|
1060
|
+
cleanupControlError = error;
|
|
1061
|
+
reportControlPlaneError(error, aborts);
|
|
1062
|
+
effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, error, false);
|
|
1063
|
+
}
|
|
988
1064
|
}
|
|
989
1065
|
if (cleanupControlError === undefined && resultAfterAbortCleanup) {
|
|
990
1066
|
effectiveResult = resultAfterAbortCleanup();
|
|
@@ -992,10 +1068,10 @@ export function createNestedPlaybookBridge(options) {
|
|
|
992
1068
|
}
|
|
993
1069
|
let finishControlError;
|
|
994
1070
|
try {
|
|
995
|
-
await emitFinish(active, effectiveResult);
|
|
1071
|
+
await emitFinish(active, effectiveResult, aborts);
|
|
996
1072
|
}
|
|
997
1073
|
catch (error) {
|
|
998
|
-
|
|
1074
|
+
reportNonAbortControlError(error, aborts);
|
|
999
1075
|
finishControlError = error;
|
|
1000
1076
|
}
|
|
1001
1077
|
finally {
|
|
@@ -1003,6 +1079,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1003
1079
|
// emission fails, do not leave a permanently unresumable call in the
|
|
1004
1080
|
// bridge and prevent disposal or a later invocation.
|
|
1005
1081
|
clear(active);
|
|
1082
|
+
options.bindActorSettlement?.(aborts);
|
|
1006
1083
|
}
|
|
1007
1084
|
if (controlError !== undefined)
|
|
1008
1085
|
throw controlError;
|
|
@@ -1012,7 +1089,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1012
1089
|
throw finishControlError;
|
|
1013
1090
|
return outputOrThrow(effectiveResult);
|
|
1014
1091
|
};
|
|
1015
|
-
const settlePending = async (active, result, controlError) => {
|
|
1092
|
+
const settlePending = async (active, result, controlError, aborts = active.aborts) => {
|
|
1016
1093
|
if (active.phase === 'settling' && active.settlement) {
|
|
1017
1094
|
await active.settlement;
|
|
1018
1095
|
return;
|
|
@@ -1026,32 +1103,40 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1026
1103
|
let cleanupControlError;
|
|
1027
1104
|
if (result.status === 'aborted' || active.signal.aborted) {
|
|
1028
1105
|
if (result.status !== 'aborted' && active.signal.aborted) {
|
|
1029
|
-
effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason
|
|
1030
|
-
new Error('Nested playbook invocation aborted'), true);
|
|
1106
|
+
effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason, true);
|
|
1031
1107
|
}
|
|
1032
1108
|
try {
|
|
1033
|
-
await drainPlaybookAbortCleanups(active.signal);
|
|
1109
|
+
await drainPlaybookAbortCleanups(active.signal, aborts);
|
|
1034
1110
|
}
|
|
1035
1111
|
catch (cleanupError) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1112
|
+
// A cleanup rejection identical to an applicable abort reason is
|
|
1113
|
+
// the cancellation's own evidence — no latch, no result override.
|
|
1114
|
+
if (!aborts.isAbortReason(cleanupError)) {
|
|
1115
|
+
cleanupControlError = cleanupError;
|
|
1116
|
+
reportControlPlaneError(cleanupError, aborts);
|
|
1117
|
+
effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, cleanupError, false);
|
|
1118
|
+
}
|
|
1039
1119
|
}
|
|
1040
1120
|
}
|
|
1041
1121
|
try {
|
|
1042
|
-
await emitFinish(active, effectiveResult);
|
|
1122
|
+
await emitFinish(active, effectiveResult, aborts);
|
|
1043
1123
|
}
|
|
1044
1124
|
catch (error) {
|
|
1045
1125
|
// A finish event is the durable return boundary. If it cannot be
|
|
1046
1126
|
// emitted and drained, the child result must not remain retryable:
|
|
1047
1127
|
// clear the identity and fail the promise actor so its parent takes
|
|
1048
|
-
// onError instead of observing a phantom suspended child.
|
|
1049
|
-
|
|
1128
|
+
// onError instead of observing a phantom suspended child. A finish
|
|
1129
|
+
// rejection that is an applicable abort reason — the invocation's
|
|
1130
|
+
// or the settling resume's — evidences cancellation, not a
|
|
1131
|
+
// control-plane failure (slc/link.md §Abort).
|
|
1132
|
+
reportControlPlaneError(error, aborts);
|
|
1050
1133
|
clear(active);
|
|
1134
|
+
options.bindActorSettlement?.(aborts);
|
|
1051
1135
|
active.deferred.reject(error);
|
|
1052
1136
|
throw error;
|
|
1053
1137
|
}
|
|
1054
1138
|
clear(active);
|
|
1139
|
+
options.bindActorSettlement?.(aborts);
|
|
1055
1140
|
if (controlError !== undefined) {
|
|
1056
1141
|
active.deferred.reject(controlError);
|
|
1057
1142
|
}
|
|
@@ -1087,6 +1172,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1087
1172
|
active.restoreRolledBack = true;
|
|
1088
1173
|
clear(active);
|
|
1089
1174
|
usedCallIds.delete(active.callId);
|
|
1175
|
+
options.bindActorSettlement?.(active.aborts);
|
|
1090
1176
|
active.deferred.reject(error);
|
|
1091
1177
|
return active;
|
|
1092
1178
|
};
|
|
@@ -1097,9 +1183,9 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1097
1183
|
const abortListener = () => {
|
|
1098
1184
|
if (active.phase !== 'suspended')
|
|
1099
1185
|
return;
|
|
1100
|
-
const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason
|
|
1186
|
+
const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason, true);
|
|
1101
1187
|
void settlePending(active, result).catch((error) => {
|
|
1102
|
-
reportBackgroundError(error);
|
|
1188
|
+
reportBackgroundError(error, active.aborts);
|
|
1103
1189
|
});
|
|
1104
1190
|
};
|
|
1105
1191
|
active.abortListener = abortListener;
|
|
@@ -1113,7 +1199,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1113
1199
|
listener(pendingCall);
|
|
1114
1200
|
}
|
|
1115
1201
|
catch (error) {
|
|
1116
|
-
reportBackgroundError(error);
|
|
1202
|
+
reportBackgroundError(error, active.aborts);
|
|
1117
1203
|
}
|
|
1118
1204
|
}
|
|
1119
1205
|
if (active.signal.aborted)
|
|
@@ -1183,8 +1269,11 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1183
1269
|
}
|
|
1184
1270
|
const controller = new AbortController();
|
|
1185
1271
|
let callSignal;
|
|
1272
|
+
let callAborts;
|
|
1186
1273
|
try {
|
|
1187
|
-
|
|
1274
|
+
const boundarySignal = options.getBoundarySignal?.();
|
|
1275
|
+
callSignal = combineAbortSignals(invocationSignal, boundarySignal, controller.signal);
|
|
1276
|
+
callAborts = createAbortReasonClassifier(invocationSignal, boundarySignal, controller.signal);
|
|
1188
1277
|
}
|
|
1189
1278
|
catch (error) {
|
|
1190
1279
|
failRestoreMode(mode, error);
|
|
@@ -1200,6 +1289,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1200
1289
|
finished: deferred(),
|
|
1201
1290
|
controller,
|
|
1202
1291
|
signal: callSignal,
|
|
1292
|
+
aborts: callAborts,
|
|
1203
1293
|
phase: 'restoring',
|
|
1204
1294
|
childSessionId: seed.childSessionId,
|
|
1205
1295
|
};
|
|
@@ -1214,8 +1304,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1214
1304
|
active.phase !== 'restoring') {
|
|
1215
1305
|
return;
|
|
1216
1306
|
}
|
|
1217
|
-
rollbackRestoredCall(mode, active.signal.reason
|
|
1218
|
-
new Error('Restored nested playbook invocation aborted'));
|
|
1307
|
+
rollbackRestoredCall(mode, active.signal.reason);
|
|
1219
1308
|
};
|
|
1220
1309
|
active.abortListener = restoreAbortListener;
|
|
1221
1310
|
active.signal.addEventListener('abort', restoreAbortListener, {
|
|
@@ -1252,8 +1341,11 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1252
1341
|
usedCallIds.add(callId);
|
|
1253
1342
|
const controller = new AbortController();
|
|
1254
1343
|
let callSignal;
|
|
1344
|
+
let callAborts;
|
|
1255
1345
|
try {
|
|
1256
|
-
|
|
1346
|
+
const boundarySignal = options.getBoundarySignal?.();
|
|
1347
|
+
callSignal = combineAbortSignals(invocationSignal, boundarySignal, controller.signal);
|
|
1348
|
+
callAborts = createAbortReasonClassifier(invocationSignal, boundarySignal, controller.signal);
|
|
1257
1349
|
}
|
|
1258
1350
|
catch (error) {
|
|
1259
1351
|
return rejectControlPlane(error);
|
|
@@ -1265,6 +1357,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1265
1357
|
finished: deferred(),
|
|
1266
1358
|
controller,
|
|
1267
1359
|
signal: callSignal,
|
|
1360
|
+
aborts: callAborts,
|
|
1268
1361
|
phase: 'starting',
|
|
1269
1362
|
};
|
|
1270
1363
|
current = active;
|
|
@@ -1273,26 +1366,39 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1273
1366
|
// for their entering state. Yield through the runtime's global queue so
|
|
1274
1367
|
// that transition/status telemetry is enqueued before call.started.
|
|
1275
1368
|
try {
|
|
1276
|
-
await options.drain();
|
|
1369
|
+
await options.drain(active.aborts);
|
|
1277
1370
|
}
|
|
1278
1371
|
catch (error) {
|
|
1279
|
-
|
|
1372
|
+
reportNonAbortControlError(error, active.aborts);
|
|
1280
1373
|
clear(active);
|
|
1281
1374
|
throw error;
|
|
1282
1375
|
}
|
|
1283
1376
|
try {
|
|
1284
|
-
await options.emitStarted({ callId, ...normalizedInput });
|
|
1377
|
+
await options.emitStarted({ callId, ...normalizedInput }, active.aborts);
|
|
1285
1378
|
}
|
|
1286
1379
|
catch (error) {
|
|
1287
|
-
|
|
1288
|
-
|
|
1380
|
+
// A start-sink rejection identical to the applicable abort
|
|
1381
|
+
// reason is the cancellation itself: the pair finishes
|
|
1382
|
+
// `aborted` and nothing is reported (slc/link.md §Abort).
|
|
1383
|
+
const controlError = active.aborts.isAbortReason(error)
|
|
1384
|
+
? undefined
|
|
1385
|
+
: error;
|
|
1386
|
+
if (controlError !== undefined) {
|
|
1387
|
+
reportControlPlaneError(controlError, active.aborts);
|
|
1388
|
+
}
|
|
1389
|
+
return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined), controlError);
|
|
1289
1390
|
}
|
|
1290
1391
|
try {
|
|
1291
|
-
await options.drain();
|
|
1392
|
+
await options.drain(active.aborts);
|
|
1292
1393
|
}
|
|
1293
1394
|
catch (error) {
|
|
1294
|
-
|
|
1295
|
-
|
|
1395
|
+
const controlError = active.aborts.isAbortReason(error)
|
|
1396
|
+
? undefined
|
|
1397
|
+
: error;
|
|
1398
|
+
if (controlError !== undefined) {
|
|
1399
|
+
reportControlPlaneError(controlError, active.aborts);
|
|
1400
|
+
}
|
|
1401
|
+
return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined), controlError);
|
|
1296
1402
|
}
|
|
1297
1403
|
const request = {
|
|
1298
1404
|
callId,
|
|
@@ -1315,7 +1421,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1315
1421
|
throw error;
|
|
1316
1422
|
});
|
|
1317
1423
|
const openingCleanup = starting.then(() => undefined, (error) => {
|
|
1318
|
-
if (isAbortReason(error
|
|
1424
|
+
if (active.aborts.isAbortReason(error))
|
|
1319
1425
|
return;
|
|
1320
1426
|
throw error;
|
|
1321
1427
|
});
|
|
@@ -1332,11 +1438,14 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1332
1438
|
rawStart = await withAbort(starting, active.signal);
|
|
1333
1439
|
}
|
|
1334
1440
|
catch (error) {
|
|
1335
|
-
const controlError = active.
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1441
|
+
const controlError = active.aborts.isAbortReason(error)
|
|
1442
|
+
? undefined
|
|
1443
|
+
: error;
|
|
1444
|
+
if (controlError !== undefined) {
|
|
1445
|
+
reportControlPlaneError(controlError, active.aborts);
|
|
1446
|
+
}
|
|
1447
|
+
const result = resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined && active.signal.aborted);
|
|
1448
|
+
return await finishImmediate(active, result, controlError, controlError === undefined && active.signal.aborted
|
|
1340
1449
|
? () => resultFromThrown(normalizedInput.playbookId, startSettled
|
|
1341
1450
|
? assignedChildSessionId(observedStart)
|
|
1342
1451
|
: undefined, error, true)
|
|
@@ -1464,8 +1573,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1464
1573
|
}
|
|
1465
1574
|
const active = mode.active;
|
|
1466
1575
|
if (active.signal.aborted) {
|
|
1467
|
-
const error = active.signal.reason
|
|
1468
|
-
new Error('Restored nested playbook invocation aborted');
|
|
1576
|
+
const error = active.signal.reason;
|
|
1469
1577
|
rollbackRestoredCall(mode, error);
|
|
1470
1578
|
restoreMode = undefined;
|
|
1471
1579
|
throw error;
|
|
@@ -1493,7 +1601,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1493
1601
|
listener(pendingCall);
|
|
1494
1602
|
}
|
|
1495
1603
|
catch (error) {
|
|
1496
|
-
reportBackgroundError(error);
|
|
1604
|
+
reportBackgroundError(error, current?.aborts);
|
|
1497
1605
|
}
|
|
1498
1606
|
}
|
|
1499
1607
|
return () => pendingListeners.delete(listener);
|
|
@@ -1521,8 +1629,17 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1521
1629
|
}
|
|
1522
1630
|
throw error;
|
|
1523
1631
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1632
|
+
// A resume whose signal is already aborted delivers nothing: the
|
|
1633
|
+
// validated child result is not consumed, no finish is emitted, and
|
|
1634
|
+
// the pending call survives for a later resume with a fresh signal
|
|
1635
|
+
// (slc/link.md §Nested playbook bridge). Identity and validation
|
|
1636
|
+
// control errors above still win — they are the caller's defects.
|
|
1637
|
+
if (signal.aborted) {
|
|
1638
|
+
throw signal.reason;
|
|
1639
|
+
}
|
|
1640
|
+
const resumeAborts = createAbortReasonClassifier(active.aborts, signal);
|
|
1641
|
+
options.bindResumeSignal?.(signal, resumeAborts);
|
|
1642
|
+
await settlePending(active, validatedResult, undefined, resumeAborts);
|
|
1526
1643
|
},
|
|
1527
1644
|
abortPending,
|
|
1528
1645
|
async dispose() {
|