@sublang/playbook 6.0.0 → 8.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 +28 -11
- package/docs/cli.md +158 -68
- package/docs/configuration.md +246 -108
- package/docs/embedding.md +71 -25
- package/package.json +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- 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 +1900 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1164 -991
- package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- 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 +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- 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 +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- 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 +101 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
- package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- 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 +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
- package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
- 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 +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- 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 +27 -23
- package/slc/link.md +140 -97
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +24 -8
- package/src/runtime.ts +29 -13
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +301 -159
- package/src/xstate-playbook-runtime.ts +405 -186
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +403 -62
- package/src/xstate-runtime.ts +566 -78
package/src/xstate-runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AnyActorRef, type PromiseActorLogic, type SnapshotFrom } from 'xstate';
|
|
2
|
-
import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlayerResult } from './runtime.js';
|
|
2
|
+
import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookSuspendedCall, PlayerResult } from './runtime.js';
|
|
3
3
|
export * from './xstate-playbook-runtime.js';
|
|
4
4
|
/**
|
|
5
5
|
* Compose invocation-lifetime and imperative-boundary cancellation without
|
|
@@ -31,7 +31,15 @@ export interface SnapshotNormalizationOptions {
|
|
|
31
31
|
}
|
|
32
32
|
export declare function normalizePlaybookSnapshot(snapshot: unknown, options?: SnapshotNormalizationOptions): PlaybookState;
|
|
33
33
|
export declare function detachPersistedMachineSnapshot(persisted: unknown): JsonValue;
|
|
34
|
-
export
|
|
34
|
+
export interface PlaybookRuntimeSnapshotValidationOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Opt in only when the restore path will prepare and confirm the suspended
|
|
37
|
+
* call transaction. The default is fail-closed so a legacy restore cannot
|
|
38
|
+
* reopen or ignore it.
|
|
39
|
+
*/
|
|
40
|
+
allowSuspendedCall?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function assertPlaybookRuntimeSnapshot(value: unknown, expectedPlaybookId: string, options?: PlaybookRuntimeSnapshotValidationOptions): PlaybookRuntimeSnapshot;
|
|
35
43
|
export interface NestedPlaybookInput {
|
|
36
44
|
stateId: string;
|
|
37
45
|
playbookId: string;
|
|
@@ -68,6 +76,15 @@ export interface PendingCallObserver {
|
|
|
68
76
|
}
|
|
69
77
|
export interface NestedPlaybookBridge<TInput extends NestedPlaybookInput = NestedPlaybookInput> extends PendingCallObserver {
|
|
70
78
|
actorLogic: PromiseActorLogic<JsonValue | undefined, TInput>;
|
|
79
|
+
/** Arm fail-closed actor startup for a snapshot with zero or one nested call. */
|
|
80
|
+
prepareRestore(call?: PlaybookSuspendedCall): void;
|
|
81
|
+
/**
|
|
82
|
+
* Commit restore startup after the persisted machine recreated exactly the
|
|
83
|
+
* expected zero or one nested invocation.
|
|
84
|
+
*/
|
|
85
|
+
confirmRestore(): void;
|
|
86
|
+
/** Complete durable identity; undefined until a normal or restored call suspends. */
|
|
87
|
+
getSuspendedCall(): PlaybookSuspendedCall | undefined;
|
|
71
88
|
resume(input: {
|
|
72
89
|
callId: string;
|
|
73
90
|
result: PlaybookCallResult;
|
package/src/xstate-runtime.js
CHANGED
|
@@ -268,6 +268,25 @@ function capturedSessionStore(descriptors) {
|
|
|
268
268
|
restore: method('restore'),
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
|
+
function capturedRoleBindings(descriptors) {
|
|
272
|
+
const captured = snapshotJsonValue(capturedDataValue(descriptors, 'roleBindings', 'playbook session roleBindings'), 'playbook session roleBindings');
|
|
273
|
+
if (!isRecord(captured)) {
|
|
274
|
+
throw new TypeError('playbook session roleBindings must be an object');
|
|
275
|
+
}
|
|
276
|
+
const bindings = {};
|
|
277
|
+
for (const [roleId, value] of Object.entries(captured)) {
|
|
278
|
+
requireNonEmptyString(roleId, 'playbook session roleBindings role id');
|
|
279
|
+
if (!isRecord(value)) {
|
|
280
|
+
throw new TypeError(`playbook session roleBindings.${roleId} must be an object`);
|
|
281
|
+
}
|
|
282
|
+
rejectUnknownKeys(value, ['playerId', 'promptIdentity'], `playbook session roleBindings.${roleId}`);
|
|
283
|
+
defineEnumerableDataProperty(bindings, roleId, Object.freeze({
|
|
284
|
+
playerId: requireNonEmptyString(value.playerId, `playbook session roleBindings.${roleId}.playerId`),
|
|
285
|
+
promptIdentity: requireNonEmptyString(value.promptIdentity, `playbook session roleBindings.${roleId}.promptIdentity`),
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
return Object.freeze(bindings);
|
|
289
|
+
}
|
|
271
290
|
/** Validate session causality and detach its immutable identity from the host. */
|
|
272
291
|
export function snapshotPlaybookSession(session) {
|
|
273
292
|
if (!isRecord(session)) {
|
|
@@ -287,6 +306,7 @@ export function snapshotPlaybookSession(session) {
|
|
|
287
306
|
const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
|
|
288
307
|
const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
|
|
289
308
|
const hasPlayerSessions = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'playerSessions');
|
|
309
|
+
const hasRoleBindings = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'roleBindings');
|
|
290
310
|
let parentSessionId;
|
|
291
311
|
let parentCallId;
|
|
292
312
|
if (depth === 0) {
|
|
@@ -320,6 +340,9 @@ export function snapshotPlaybookSession(session) {
|
|
|
320
340
|
const playerSessions = hasPlayerSessions
|
|
321
341
|
? capturedSessionStore(sessionDescriptors)
|
|
322
342
|
: undefined;
|
|
343
|
+
const roleBindings = hasRoleBindings
|
|
344
|
+
? capturedRoleBindings(sessionDescriptors)
|
|
345
|
+
: undefined;
|
|
323
346
|
return Object.freeze({
|
|
324
347
|
sessionId,
|
|
325
348
|
playbookId,
|
|
@@ -327,6 +350,7 @@ export function snapshotPlaybookSession(session) {
|
|
|
327
350
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
328
351
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
329
352
|
depth,
|
|
353
|
+
...(roleBindings === undefined ? {} : { roleBindings }),
|
|
330
354
|
...(playerSessions === undefined ? {} : { playerSessions }),
|
|
331
355
|
ports,
|
|
332
356
|
});
|
|
@@ -526,61 +550,153 @@ const SNAPSHOT_SEQUENCE_KEYS = [
|
|
|
526
550
|
'playerCall',
|
|
527
551
|
'playbookCall',
|
|
528
552
|
];
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
553
|
+
function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
|
|
554
|
+
const captured = snapshotJsonValue(value, path);
|
|
555
|
+
if (!isRecord(captured)) {
|
|
556
|
+
throw new TypeError(`${path} must be an object`);
|
|
557
|
+
}
|
|
558
|
+
rejectUnknownKeys(captured, ['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'], path);
|
|
559
|
+
const call = {
|
|
560
|
+
callId: requireNonEmptyString(captured.callId, `${path}.callId`),
|
|
561
|
+
stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
|
|
562
|
+
playbookId: requireNonEmptyString(captured.playbookId, `${path}.playbookId`),
|
|
563
|
+
text: requireNonEmptyString(captured.text, `${path}.text`),
|
|
564
|
+
childSessionId: requireNonEmptyString(captured.childSessionId, `${path}.childSessionId`),
|
|
565
|
+
};
|
|
566
|
+
if (own(captured, 'turnId')) {
|
|
567
|
+
if (!Number.isSafeInteger(captured.turnId) ||
|
|
568
|
+
captured.turnId <= 0) {
|
|
569
|
+
throw new TypeError(`${path}.turnId must be a positive integer`);
|
|
570
|
+
}
|
|
571
|
+
call.turnId = captured.turnId;
|
|
572
|
+
}
|
|
573
|
+
return Object.freeze(call);
|
|
574
|
+
}
|
|
575
|
+
// DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
|
|
576
|
+
// schema-3 runtime snapshot before restore touches any state. A suspended
|
|
577
|
+
// call is rejected unless the restore path explicitly promises to seed and
|
|
578
|
+
// claim it; older schemas are rejected rather than guessing role identity.
|
|
579
|
+
export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options = {}) {
|
|
580
|
+
const snapshot = snapshotJsonValue(value, 'runtime snapshot');
|
|
581
|
+
if (!isRecord(snapshot)) {
|
|
534
582
|
throw new TypeError('runtime snapshot must be an object');
|
|
535
583
|
}
|
|
536
|
-
|
|
537
|
-
|
|
584
|
+
const capturedOptions = snapshotJsonValue(options, 'runtime snapshot validation options');
|
|
585
|
+
if (!isRecord(capturedOptions)) {
|
|
586
|
+
throw new TypeError('runtime snapshot validation options must be an object');
|
|
587
|
+
}
|
|
588
|
+
rejectUnknownKeys(capturedOptions, ['allowSuspendedCall'], 'runtime snapshot validation options');
|
|
589
|
+
if (capturedOptions.allowSuspendedCall !== undefined &&
|
|
590
|
+
typeof capturedOptions.allowSuspendedCall !== 'boolean') {
|
|
591
|
+
throw new TypeError('runtime snapshot validation options.allowSuspendedCall must be boolean');
|
|
592
|
+
}
|
|
593
|
+
const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
|
|
594
|
+
if (snapshot.schemaVersion !== 3) {
|
|
595
|
+
throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
|
|
596
|
+
}
|
|
597
|
+
rejectUnknownKeys(snapshot, [
|
|
598
|
+
'schemaVersion',
|
|
599
|
+
'playbookId',
|
|
600
|
+
'machine',
|
|
601
|
+
'roleResumeTokens',
|
|
602
|
+
'sequences',
|
|
603
|
+
'state',
|
|
604
|
+
'pendingBossQuestions',
|
|
605
|
+
'suspendedCall',
|
|
606
|
+
], 'runtime snapshot');
|
|
607
|
+
let suspendedCall;
|
|
608
|
+
if (own(snapshot, 'suspendedCall')) {
|
|
609
|
+
suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
|
|
610
|
+
if (!allowSuspendedCall) {
|
|
611
|
+
throw new TypeError('runtime snapshot suspendedCall requires a restore path that explicitly allows it');
|
|
612
|
+
}
|
|
538
613
|
}
|
|
539
|
-
const playbookId = requireNonEmptyString(
|
|
614
|
+
const playbookId = requireNonEmptyString(snapshot.playbookId, 'runtime snapshot playbookId');
|
|
540
615
|
if (playbookId !== expectedPlaybookId) {
|
|
541
616
|
throw new TypeError(`runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`);
|
|
542
617
|
}
|
|
543
|
-
if (!isRecord(
|
|
618
|
+
if (!isRecord(snapshot.machine)) {
|
|
544
619
|
throw new TypeError('runtime snapshot machine must be an object');
|
|
545
620
|
}
|
|
546
|
-
const machine =
|
|
547
|
-
if (!isRecord(
|
|
548
|
-
throw new TypeError('runtime snapshot
|
|
621
|
+
const machine = snapshot.machine;
|
|
622
|
+
if (!isRecord(snapshot.roleResumeTokens)) {
|
|
623
|
+
throw new TypeError('runtime snapshot roleResumeTokens must be an object');
|
|
549
624
|
}
|
|
550
|
-
const
|
|
551
|
-
for (const [
|
|
552
|
-
defineEnumerableDataProperty(
|
|
625
|
+
const roleResumeTokens = {};
|
|
626
|
+
for (const [roleId, token] of Object.entries(snapshot.roleResumeTokens)) {
|
|
627
|
+
defineEnumerableDataProperty(roleResumeTokens, requireNonEmptyString(roleId, 'runtime snapshot roleResumeTokens role id'), requireNonEmptyString(token, `runtime snapshot roleResumeTokens.${roleId}`));
|
|
553
628
|
}
|
|
554
|
-
if (!isRecord(
|
|
629
|
+
if (!isRecord(snapshot.sequences)) {
|
|
555
630
|
throw new TypeError('runtime snapshot sequences must be an object');
|
|
556
631
|
}
|
|
632
|
+
rejectUnknownKeys(snapshot.sequences, [...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'], 'runtime snapshot sequences');
|
|
557
633
|
const sequences = {};
|
|
558
634
|
for (const key of SNAPSHOT_SEQUENCE_KEYS) {
|
|
559
|
-
const sequence =
|
|
635
|
+
const sequence = snapshot.sequences[key];
|
|
560
636
|
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
561
637
|
throw new TypeError(`runtime snapshot sequences.${key} must be a non-negative integer`);
|
|
562
638
|
}
|
|
563
639
|
sequences[key] = sequence;
|
|
564
640
|
}
|
|
565
|
-
const captainCall =
|
|
641
|
+
const captainCall = snapshot.sequences.captainCall;
|
|
566
642
|
if (captainCall !== undefined) {
|
|
567
643
|
if (!Number.isSafeInteger(captainCall) || captainCall < 0) {
|
|
568
644
|
throw new TypeError('runtime snapshot sequences.captainCall must be a non-negative integer');
|
|
569
645
|
}
|
|
570
646
|
sequences.captainCall = captainCall;
|
|
571
647
|
}
|
|
572
|
-
validateState(
|
|
573
|
-
const state =
|
|
574
|
-
if (
|
|
648
|
+
validateState(snapshot.state, 'runtime snapshot state');
|
|
649
|
+
const state = snapshot.state;
|
|
650
|
+
if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
|
|
651
|
+
throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires suspendedCall`);
|
|
652
|
+
}
|
|
653
|
+
if (suspendedCall) {
|
|
654
|
+
if (sequences.playbookCall === 0) {
|
|
655
|
+
throw new TypeError('runtime snapshot suspendedCall requires sequences.playbookCall greater than zero');
|
|
656
|
+
}
|
|
657
|
+
if (suspendedCall.turnId !== undefined &&
|
|
658
|
+
suspendedCall.turnId > sequences.turn) {
|
|
659
|
+
throw new TypeError('runtime snapshot suspendedCall.turnId must not exceed sequences.turn');
|
|
660
|
+
}
|
|
661
|
+
if (state.status !== 'active' || !state.quiescent) {
|
|
662
|
+
throw new TypeError('runtime snapshot suspendedCall requires an active quiescent state');
|
|
663
|
+
}
|
|
664
|
+
if (!state.tags.includes(SUSPENDED_TAG)) {
|
|
665
|
+
throw new TypeError(`runtime snapshot suspendedCall requires state tag ${SUSPENDED_TAG}`);
|
|
666
|
+
}
|
|
667
|
+
if (!state.activeStateIds.includes(suspendedCall.stateId)) {
|
|
668
|
+
throw new TypeError('runtime snapshot suspendedCall.stateId must be active in snapshot state');
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!Array.isArray(snapshot.pendingBossQuestions)) {
|
|
575
672
|
throw new TypeError('runtime snapshot pendingBossQuestions must be an array');
|
|
576
673
|
}
|
|
577
|
-
const pendingBossQuestions =
|
|
674
|
+
const pendingBossQuestions = snapshot.pendingBossQuestions.map((entry, index) => {
|
|
578
675
|
const path = `runtime snapshot pendingBossQuestions[${index}]`;
|
|
579
676
|
if (!isRecord(entry))
|
|
580
677
|
throw new TypeError(`${path} must be an object`);
|
|
678
|
+
rejectUnknownKeys(entry, ['questionId', 'asker', 'question', 'sourceItem'], path);
|
|
679
|
+
if (!isRecord(entry.asker)) {
|
|
680
|
+
throw new TypeError(`${path}.asker must be an object`);
|
|
681
|
+
}
|
|
682
|
+
let asker;
|
|
683
|
+
if (entry.asker.kind === 'captain') {
|
|
684
|
+
rejectUnknownKeys(entry.asker, ['kind'], `${path}.asker`);
|
|
685
|
+
asker = Object.freeze({ kind: 'captain' });
|
|
686
|
+
}
|
|
687
|
+
else if (entry.asker.kind === 'role') {
|
|
688
|
+
rejectUnknownKeys(entry.asker, ['kind', 'roleId'], `${path}.asker`);
|
|
689
|
+
asker = Object.freeze({
|
|
690
|
+
kind: 'role',
|
|
691
|
+
roleId: requireNonEmptyString(entry.asker.roleId, `${path}.asker.roleId`),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
throw new TypeError(`${path}.asker.kind must be "captain" or "role"`);
|
|
696
|
+
}
|
|
581
697
|
const question = {
|
|
582
698
|
questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
|
|
583
|
-
|
|
699
|
+
asker,
|
|
584
700
|
question: requireNonEmptyString(entry.question, `${path}.question`),
|
|
585
701
|
...(entry.sourceItem === undefined
|
|
586
702
|
? {}
|
|
@@ -590,14 +706,18 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId) {
|
|
|
590
706
|
};
|
|
591
707
|
return Object.freeze(question);
|
|
592
708
|
});
|
|
593
|
-
|
|
594
|
-
schemaVersion: 1,
|
|
709
|
+
const fields = {
|
|
595
710
|
playbookId,
|
|
596
711
|
machine,
|
|
597
|
-
|
|
712
|
+
roleResumeTokens: Object.freeze(roleResumeTokens),
|
|
598
713
|
sequences: Object.freeze(sequences),
|
|
599
714
|
state,
|
|
600
715
|
pendingBossQuestions: Object.freeze(pendingBossQuestions),
|
|
716
|
+
};
|
|
717
|
+
return Object.freeze({
|
|
718
|
+
schemaVersion: 3,
|
|
719
|
+
...fields,
|
|
720
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
601
721
|
});
|
|
602
722
|
}
|
|
603
723
|
export class NestedPlaybookCallError extends Error {
|
|
@@ -689,6 +809,9 @@ export function validatePlayerResult(value, path = 'player result') {
|
|
|
689
809
|
rejectUnknownKeys(result, ['status', 'resumeToken', 'finalText', 'error'], path);
|
|
690
810
|
validateRunStatus(result.status, `${path}.status`);
|
|
691
811
|
validateOptionalString(result, 'resumeToken', path);
|
|
812
|
+
if (result.resumeToken !== undefined) {
|
|
813
|
+
requireNonEmptyString(result.resumeToken, `${path}.resumeToken`);
|
|
814
|
+
}
|
|
692
815
|
validateOptionalString(result, 'finalText', path);
|
|
693
816
|
validateOptionalString(result, 'error', path);
|
|
694
817
|
return result;
|
|
@@ -821,6 +944,7 @@ function outputOrThrow(result) {
|
|
|
821
944
|
}
|
|
822
945
|
export function createNestedPlaybookBridge(options) {
|
|
823
946
|
let current;
|
|
947
|
+
let restoreMode;
|
|
824
948
|
let disposed = false;
|
|
825
949
|
const usedCallIds = new Set();
|
|
826
950
|
const pendingListeners = new Set();
|
|
@@ -853,10 +977,29 @@ export function createNestedPlaybookBridge(options) {
|
|
|
853
977
|
childSessionId: active.childSessionId,
|
|
854
978
|
}
|
|
855
979
|
: undefined;
|
|
856
|
-
const
|
|
980
|
+
const suspendedIdentity = (active) => active?.phase === 'suspended' && active.childSessionId
|
|
981
|
+
? Object.freeze({
|
|
982
|
+
callId: active.callId,
|
|
983
|
+
stateId: active.input.stateId,
|
|
984
|
+
playbookId: active.input.playbookId,
|
|
985
|
+
text: active.input.text,
|
|
986
|
+
childSessionId: active.childSessionId,
|
|
987
|
+
...(active.turnId === undefined ? {} : { turnId: active.turnId }),
|
|
988
|
+
})
|
|
989
|
+
: undefined;
|
|
990
|
+
const failRestoreMode = (mode, error) => {
|
|
991
|
+
mode.state = 'failed';
|
|
992
|
+
mode.error = error;
|
|
993
|
+
reportControlPlaneError(error);
|
|
994
|
+
};
|
|
995
|
+
const detachAbortListener = (active) => {
|
|
857
996
|
if (active.abortListener) {
|
|
858
997
|
active.signal.removeEventListener('abort', active.abortListener);
|
|
998
|
+
active.abortListener = undefined;
|
|
859
999
|
}
|
|
1000
|
+
};
|
|
1001
|
+
const clear = (active) => {
|
|
1002
|
+
detachAbortListener(active);
|
|
860
1003
|
if (current === active)
|
|
861
1004
|
current = undefined;
|
|
862
1005
|
};
|
|
@@ -972,23 +1115,171 @@ export function createNestedPlaybookBridge(options) {
|
|
|
972
1115
|
throw error;
|
|
973
1116
|
}
|
|
974
1117
|
};
|
|
1118
|
+
const rollbackRestoredCall = (mode, error) => {
|
|
1119
|
+
const active = mode.active;
|
|
1120
|
+
mode.state = 'failed';
|
|
1121
|
+
mode.error = error;
|
|
1122
|
+
mode.active = undefined;
|
|
1123
|
+
if (!active)
|
|
1124
|
+
return undefined;
|
|
1125
|
+
active.phase = 'settling';
|
|
1126
|
+
active.restoreRolledBack = true;
|
|
1127
|
+
clear(active);
|
|
1128
|
+
usedCallIds.delete(active.callId);
|
|
1129
|
+
active.deferred.reject(error);
|
|
1130
|
+
return active;
|
|
1131
|
+
};
|
|
1132
|
+
const publishSuspendedCall = (active) => {
|
|
1133
|
+
if (active.phase !== 'suspended') {
|
|
1134
|
+
throw new Error(`playbook call ${active.callId} is not suspended`);
|
|
1135
|
+
}
|
|
1136
|
+
const abortListener = () => {
|
|
1137
|
+
if (active.phase !== 'suspended')
|
|
1138
|
+
return;
|
|
1139
|
+
const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ?? new Error('Nested playbook invocation aborted'), true);
|
|
1140
|
+
void settlePending(active, result).catch((error) => {
|
|
1141
|
+
reportBackgroundError(error);
|
|
1142
|
+
});
|
|
1143
|
+
};
|
|
1144
|
+
active.abortListener = abortListener;
|
|
1145
|
+
active.signal.addEventListener('abort', abortListener, { once: true });
|
|
1146
|
+
const pendingCall = pendingIdentity(active);
|
|
1147
|
+
if (!pendingCall) {
|
|
1148
|
+
throw new Error('suspended call identity was not recorded');
|
|
1149
|
+
}
|
|
1150
|
+
for (const listener of pendingListeners) {
|
|
1151
|
+
try {
|
|
1152
|
+
listener(pendingCall);
|
|
1153
|
+
}
|
|
1154
|
+
catch (error) {
|
|
1155
|
+
reportBackgroundError(error);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (active.signal.aborted)
|
|
1159
|
+
abortListener();
|
|
1160
|
+
};
|
|
1161
|
+
const waitOnSuspendedCall = async (active) => {
|
|
1162
|
+
publishSuspendedCall(active);
|
|
1163
|
+
return await active.deferred.promise;
|
|
1164
|
+
};
|
|
975
1165
|
const actorLogic = fromPromise(async ({ input, signal: invocationSignal }) => {
|
|
976
1166
|
if (disposed) {
|
|
977
1167
|
rejectControlPlane(new Error('nested playbook bridge is disposed'));
|
|
978
1168
|
}
|
|
1169
|
+
const normalizedInput = (() => {
|
|
1170
|
+
try {
|
|
1171
|
+
return {
|
|
1172
|
+
stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
|
|
1173
|
+
playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
|
|
1174
|
+
text: requireNonEmptyString(input.text, 'playbook input text'),
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
catch (error) {
|
|
1178
|
+
const mode = restoreMode;
|
|
1179
|
+
if (mode) {
|
|
1180
|
+
if (mode.state === 'claimed') {
|
|
1181
|
+
rollbackRestoredCall(mode, error);
|
|
1182
|
+
reportControlPlaneError(error);
|
|
1183
|
+
}
|
|
1184
|
+
else
|
|
1185
|
+
failRestoreMode(mode, error);
|
|
1186
|
+
throw error;
|
|
1187
|
+
}
|
|
1188
|
+
return rejectControlPlane(error);
|
|
1189
|
+
}
|
|
1190
|
+
})();
|
|
1191
|
+
const mode = restoreMode;
|
|
1192
|
+
if (mode) {
|
|
1193
|
+
if (mode.state !== 'armed') {
|
|
1194
|
+
const callId = mode.call?.callId ?? 'without a descriptor';
|
|
1195
|
+
const error = new Error(mode.state === 'claimed'
|
|
1196
|
+
? `restored playbook call ${callId} was claimed more than once`
|
|
1197
|
+
: `restored playbook call ${callId} is no longer claimable`);
|
|
1198
|
+
if (mode.state === 'claimed')
|
|
1199
|
+
rollbackRestoredCall(mode, error);
|
|
1200
|
+
else
|
|
1201
|
+
mode.error ??= error;
|
|
1202
|
+
reportControlPlaneError(error);
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1205
|
+
const seed = mode.call;
|
|
1206
|
+
if (!seed) {
|
|
1207
|
+
const error = new Error('restored machine invoked a nested playbook without a suspendedCall descriptor');
|
|
1208
|
+
failRestoreMode(mode, error);
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
for (const field of ['stateId', 'playbookId', 'text']) {
|
|
1212
|
+
if (normalizedInput[field] !== seed[field]) {
|
|
1213
|
+
const error = new Error(`restored playbook call ${seed.callId} ${field} does not match its persisted input`);
|
|
1214
|
+
failRestoreMode(mode, error);
|
|
1215
|
+
throw error;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (usedCallIds.has(seed.callId)) {
|
|
1219
|
+
const error = new Error(`restored duplicate playbook call id ${seed.callId}`);
|
|
1220
|
+
failRestoreMode(mode, error);
|
|
1221
|
+
throw error;
|
|
1222
|
+
}
|
|
1223
|
+
const controller = new AbortController();
|
|
1224
|
+
let callSignal;
|
|
1225
|
+
try {
|
|
1226
|
+
callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
|
|
1227
|
+
}
|
|
1228
|
+
catch (error) {
|
|
1229
|
+
failRestoreMode(mode, error);
|
|
1230
|
+
throw error;
|
|
1231
|
+
}
|
|
1232
|
+
const active = {
|
|
1233
|
+
callId: seed.callId,
|
|
1234
|
+
input: normalizedInput,
|
|
1235
|
+
...(seed.turnId === undefined
|
|
1236
|
+
? {}
|
|
1237
|
+
: { turnId: seed.turnId }),
|
|
1238
|
+
deferred: deferred(),
|
|
1239
|
+
finished: deferred(),
|
|
1240
|
+
controller,
|
|
1241
|
+
signal: callSignal,
|
|
1242
|
+
phase: 'restoring',
|
|
1243
|
+
childSessionId: seed.childSessionId,
|
|
1244
|
+
};
|
|
1245
|
+
usedCallIds.add(active.callId);
|
|
1246
|
+
current = active;
|
|
1247
|
+
mode.state = 'claimed';
|
|
1248
|
+
mode.active = active;
|
|
1249
|
+
const restoreAbortListener = () => {
|
|
1250
|
+
if (restoreMode !== mode ||
|
|
1251
|
+
mode.state !== 'claimed' ||
|
|
1252
|
+
mode.active !== active ||
|
|
1253
|
+
active.phase !== 'restoring') {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
rollbackRestoredCall(mode, active.signal.reason ??
|
|
1257
|
+
new Error('Restored nested playbook invocation aborted'));
|
|
1258
|
+
};
|
|
1259
|
+
active.abortListener = restoreAbortListener;
|
|
1260
|
+
active.signal.addEventListener('abort', restoreAbortListener, {
|
|
1261
|
+
once: true,
|
|
1262
|
+
});
|
|
1263
|
+
if (active.signal.aborted)
|
|
1264
|
+
restoreAbortListener();
|
|
1265
|
+
try {
|
|
1266
|
+
return await active.deferred.promise;
|
|
1267
|
+
}
|
|
1268
|
+
catch (error) {
|
|
1269
|
+
if (!active.restoreRolledBack)
|
|
1270
|
+
active.runError = error;
|
|
1271
|
+
throw error;
|
|
1272
|
+
}
|
|
1273
|
+
finally {
|
|
1274
|
+
active.finished.resolve(undefined);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
979
1277
|
if (current) {
|
|
980
1278
|
rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
|
|
981
1279
|
}
|
|
982
|
-
const
|
|
1280
|
+
const callId = (() => {
|
|
983
1281
|
try {
|
|
984
|
-
return
|
|
985
|
-
{
|
|
986
|
-
stateId: requireNonEmptyString(input.stateId, 'playbook input stateId'),
|
|
987
|
-
playbookId: requireNonEmptyString(input.playbookId, 'playbook input playbookId'),
|
|
988
|
-
text: requireNonEmptyString(input.text, 'playbook input text'),
|
|
989
|
-
},
|
|
990
|
-
requireNonEmptyString(options.nextCallId(), 'allocated playbook call id'),
|
|
991
|
-
];
|
|
1282
|
+
return requireNonEmptyString(options.nextCallId(), 'allocated playbook call id');
|
|
992
1283
|
}
|
|
993
1284
|
catch (error) {
|
|
994
1285
|
return rejectControlPlane(error);
|
|
@@ -1119,32 +1410,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1119
1410
|
}
|
|
1120
1411
|
active.phase = 'suspended';
|
|
1121
1412
|
active.childSessionId = start.childSessionId;
|
|
1122
|
-
|
|
1123
|
-
if (active.phase !== 'suspended')
|
|
1124
|
-
return;
|
|
1125
|
-
const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
|
|
1126
|
-
new Error('Nested playbook invocation aborted'), true);
|
|
1127
|
-
void settlePending(active, result).catch((error) => {
|
|
1128
|
-
reportBackgroundError(error);
|
|
1129
|
-
});
|
|
1130
|
-
};
|
|
1131
|
-
active.abortListener = abortListener;
|
|
1132
|
-
active.signal.addEventListener('abort', abortListener, { once: true });
|
|
1133
|
-
const pendingCall = pendingIdentity(active);
|
|
1134
|
-
if (!pendingCall) {
|
|
1135
|
-
throw new Error('suspended call identity was not recorded');
|
|
1136
|
-
}
|
|
1137
|
-
for (const listener of pendingListeners) {
|
|
1138
|
-
try {
|
|
1139
|
-
listener(pendingCall);
|
|
1140
|
-
}
|
|
1141
|
-
catch (error) {
|
|
1142
|
-
reportBackgroundError(error);
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
if (active.signal.aborted)
|
|
1146
|
-
abortListener();
|
|
1147
|
-
return await active.deferred.promise;
|
|
1413
|
+
return await waitOnSuspendedCall(active);
|
|
1148
1414
|
}
|
|
1149
1415
|
catch (error) {
|
|
1150
1416
|
active.runError = error;
|
|
@@ -1155,6 +1421,16 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1155
1421
|
}
|
|
1156
1422
|
});
|
|
1157
1423
|
const abortPending = async (error = new Error('Nested playbook call aborted')) => {
|
|
1424
|
+
const mode = restoreMode;
|
|
1425
|
+
if (mode) {
|
|
1426
|
+
restoreMode = undefined;
|
|
1427
|
+
const restored = mode.state === 'claimed'
|
|
1428
|
+
? rollbackRestoredCall(mode, error)
|
|
1429
|
+
: undefined;
|
|
1430
|
+
if (restored)
|
|
1431
|
+
await restored.finished.promise;
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1158
1434
|
const active = current;
|
|
1159
1435
|
if (!active)
|
|
1160
1436
|
return;
|
|
@@ -1182,6 +1458,70 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1182
1458
|
return {
|
|
1183
1459
|
actorLogic,
|
|
1184
1460
|
getPendingCall: () => pendingIdentity(current),
|
|
1461
|
+
getSuspendedCall: () => suspendedIdentity(current),
|
|
1462
|
+
prepareRestore(call) {
|
|
1463
|
+
// Capture the complete host-owned descriptor before observing or
|
|
1464
|
+
// mutating bridge state, so a rejected preparation cannot leave state.
|
|
1465
|
+
const captured = call === undefined
|
|
1466
|
+
? undefined
|
|
1467
|
+
: snapshotSuspendedCall(call, 'restored playbook call');
|
|
1468
|
+
if (disposed) {
|
|
1469
|
+
rejectControlPlane(new Error('nested playbook bridge is disposed'));
|
|
1470
|
+
}
|
|
1471
|
+
if (current) {
|
|
1472
|
+
rejectControlPlane(new Error(`playbook call ${current.callId} is already outstanding`));
|
|
1473
|
+
}
|
|
1474
|
+
if (restoreMode) {
|
|
1475
|
+
rejectControlPlane(new Error('nested playbook bridge restore is already prepared'));
|
|
1476
|
+
}
|
|
1477
|
+
if (captured && usedCallIds.has(captured.callId)) {
|
|
1478
|
+
rejectControlPlane(new Error(`restored duplicate playbook call id ${captured.callId}`));
|
|
1479
|
+
}
|
|
1480
|
+
restoreMode = {
|
|
1481
|
+
...(captured === undefined ? {} : { call: captured }),
|
|
1482
|
+
state: 'armed',
|
|
1483
|
+
};
|
|
1484
|
+
},
|
|
1485
|
+
confirmRestore() {
|
|
1486
|
+
const mode = restoreMode;
|
|
1487
|
+
if (!mode) {
|
|
1488
|
+
throw new Error('nested playbook bridge restore is not prepared');
|
|
1489
|
+
}
|
|
1490
|
+
if (mode.state === 'failed') {
|
|
1491
|
+
restoreMode = undefined;
|
|
1492
|
+
throw mode.error;
|
|
1493
|
+
}
|
|
1494
|
+
if (mode.call === undefined) {
|
|
1495
|
+
restoreMode = undefined;
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (mode.state !== 'claimed' || !mode.active) {
|
|
1499
|
+
const error = new Error(`restored playbook call ${mode.call.callId} was not claimed by actor startup`);
|
|
1500
|
+
restoreMode = undefined;
|
|
1501
|
+
reportControlPlaneError(error);
|
|
1502
|
+
throw error;
|
|
1503
|
+
}
|
|
1504
|
+
const active = mode.active;
|
|
1505
|
+
if (active.signal.aborted) {
|
|
1506
|
+
const error = active.signal.reason ??
|
|
1507
|
+
new Error('Restored nested playbook invocation aborted');
|
|
1508
|
+
rollbackRestoredCall(mode, error);
|
|
1509
|
+
restoreMode = undefined;
|
|
1510
|
+
throw error;
|
|
1511
|
+
}
|
|
1512
|
+
try {
|
|
1513
|
+
detachAbortListener(active);
|
|
1514
|
+
active.phase = 'suspended';
|
|
1515
|
+
restoreMode = undefined;
|
|
1516
|
+
publishSuspendedCall(active);
|
|
1517
|
+
}
|
|
1518
|
+
catch (error) {
|
|
1519
|
+
rollbackRestoredCall(mode, error);
|
|
1520
|
+
restoreMode = undefined;
|
|
1521
|
+
reportControlPlaneError(error);
|
|
1522
|
+
throw error;
|
|
1523
|
+
}
|
|
1524
|
+
},
|
|
1185
1525
|
subscribePendingCall(listener) {
|
|
1186
1526
|
if (disposed)
|
|
1187
1527
|
return () => undefined;
|
|
@@ -1237,6 +1577,7 @@ export function createNestedPlaybookBridge(options) {
|
|
|
1237
1577
|
}
|
|
1238
1578
|
}
|
|
1239
1579
|
finally {
|
|
1580
|
+
restoreMode = undefined;
|
|
1240
1581
|
pendingListeners.clear();
|
|
1241
1582
|
}
|
|
1242
1583
|
},
|