@sublang/playbook 12.2.2 → 13.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/docs/cli.md +56 -52
- package/docs/configuration.md +2 -2
- package/docs/embedding.md +68 -32
- package/package.json +11 -3
- package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +13 -7
- package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
- package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
- package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
- package/reference/sdlc/code.playbook/bin/run.js +62 -18
- package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +638 -70
- package/reference/sdlc/code.playbook/code.fsm.js +43 -5
- package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
- package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
- package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
- package/reference/sdlc/code.playbook/session-host.js +18 -0
- package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
- package/reference/sdlc/code.playbook/session-store.js +22 -6
- package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
- package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
- package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
- package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
- package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
- package/reference/sdlc/review.playbook/review.fsm.js +11 -1
- package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
- package/slc/gears2fsm.md +21 -3
- package/slc/link.md +21 -2
- package/src/runtime.d.ts +7 -0
- package/src/runtime.ts +12 -0
- package/src/xstate-playbook-runtime.d.ts +13 -0
- package/src/xstate-playbook-runtime.js +75 -13
- package/src/xstate-playbook-runtime.ts +87 -21
- package/src/xstate-runtime.js +35 -4
- package/src/xstate-runtime.ts +49 -4
|
@@ -1073,6 +1073,45 @@ export function stateDescriptionsFromMachine(machine) {
|
|
|
1073
1073
|
}
|
|
1074
1074
|
return descriptions;
|
|
1075
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* DR-048: each root final state's declared terminal kind, read from
|
|
1078
|
+
* `meta.playbook.terminal` in `machine.config`. The kind is compiled
|
|
1079
|
+
* metadata — the compiler derives it from the Source's own outcome wording,
|
|
1080
|
+
* exactly as it derives the state's description — so a caller learns whether
|
|
1081
|
+
* a completed child succeeded from the machine it reached, never from the
|
|
1082
|
+
* child's output fields or an agent's prose.
|
|
1083
|
+
*
|
|
1084
|
+
* A machine whose final states declare no kind yields an empty map and keeps
|
|
1085
|
+
* the pre-DR-048 delivery. A `terminal` on a non-final state, or a value
|
|
1086
|
+
* other than `success` or `failure`, is a malformed artifact and throws.
|
|
1087
|
+
*/
|
|
1088
|
+
export function terminalOutcomesFromMachine(machine, label = 'playbook') {
|
|
1089
|
+
const kinds = new Map();
|
|
1090
|
+
const config = machine.config;
|
|
1091
|
+
if (!isPlainObject(config) || !isPlainObject(config.states))
|
|
1092
|
+
return kinds;
|
|
1093
|
+
for (const [key, stateDef] of Object.entries(config.states)) {
|
|
1094
|
+
if (!isPlainObject(stateDef))
|
|
1095
|
+
continue;
|
|
1096
|
+
const playbook = isPlainObject(stateDef.meta)
|
|
1097
|
+
? stateDef.meta.playbook
|
|
1098
|
+
: undefined;
|
|
1099
|
+
const declared = isPlainObject(playbook) ? playbook.terminal : undefined;
|
|
1100
|
+
if (declared === undefined)
|
|
1101
|
+
continue;
|
|
1102
|
+
if (declared !== 'success' && declared !== 'failure') {
|
|
1103
|
+
throw new TypeError(`${label} state ${key} declares meta.playbook.terminal ` +
|
|
1104
|
+
`${JSON.stringify(declared)}; only 'success' or 'failure' is a ` +
|
|
1105
|
+
'terminal kind');
|
|
1106
|
+
}
|
|
1107
|
+
if (stateDef.type !== 'final') {
|
|
1108
|
+
throw new TypeError(`${label} state ${key} declares meta.playbook.terminal but is not ` +
|
|
1109
|
+
'a final state');
|
|
1110
|
+
}
|
|
1111
|
+
kinds.set(key, declared);
|
|
1112
|
+
}
|
|
1113
|
+
return kinds;
|
|
1114
|
+
}
|
|
1076
1115
|
/**
|
|
1077
1116
|
* First configured target of `eventType` from the state with `stateId`,
|
|
1078
1117
|
* falling back to the machine root's own transitions. Used only to pick the
|
|
@@ -1834,6 +1873,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1834
1873
|
// DR-029: source state descriptions label the control actions the
|
|
1835
1874
|
// runtime advertises through `describe()`.
|
|
1836
1875
|
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
1876
|
+
// DR-048: a malformed terminal declaration is a control-plane defect of the
|
|
1877
|
+
// artifact, so it fails construction rather than at the one run that
|
|
1878
|
+
// happens to reach that final state.
|
|
1879
|
+
const terminalKinds = terminalOutcomesFromMachine(machine, label);
|
|
1837
1880
|
const roleStatesDescriptor = specDescriptors.roleStates;
|
|
1838
1881
|
if (roleStatesDescriptor !== undefined &&
|
|
1839
1882
|
!Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')) {
|
|
@@ -2804,7 +2847,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2804
2847
|
};
|
|
2805
2848
|
}
|
|
2806
2849
|
function detachedPlayerContinuation(roleId, playerId) {
|
|
2807
|
-
return snapshotJsonValue(
|
|
2850
|
+
return snapshotJsonValue({ v: 1, playerId: playerId ?? roleId }, `${label} deferred player continuation`);
|
|
2808
2851
|
}
|
|
2809
2852
|
function completionEvidenceFor(input, roleId, playerId, signal, operationId) {
|
|
2810
2853
|
return async (completion) => {
|
|
@@ -4275,10 +4318,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
4275
4318
|
const stateDescription = !hasUnresolvedReconciliation()
|
|
4276
4319
|
? stateDescriptionFor(state)
|
|
4277
4320
|
: undefined;
|
|
4321
|
+
// DR-048: the reached final state's compiled terminal meaning, read
|
|
4322
|
+
// from the artifact. It is withheld exactly when the published
|
|
4323
|
+
// description is, so an unresolved reconciliation publishes no
|
|
4324
|
+
// terminal meaning at all.
|
|
4325
|
+
const terminal = hasUnresolvedReconciliation() || state.stateId === undefined
|
|
4326
|
+
? undefined
|
|
4327
|
+
: terminalOutcomeFor(state.stateId, stateDescription);
|
|
4278
4328
|
return {
|
|
4279
4329
|
outcome,
|
|
4280
4330
|
state,
|
|
4281
4331
|
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
4332
|
+
...(terminal === undefined ? {} : { terminal }),
|
|
4282
4333
|
...(output === undefined
|
|
4283
4334
|
? {}
|
|
4284
4335
|
: {
|
|
@@ -4629,6 +4680,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
4629
4680
|
}
|
|
4630
4681
|
return undefined;
|
|
4631
4682
|
}
|
|
4683
|
+
// DR-048: the public terminal record for a reached final state, present
|
|
4684
|
+
// only for an artifact that declares that state's kind.
|
|
4685
|
+
function terminalOutcomeFor(stateId, description) {
|
|
4686
|
+
const kind = terminalKinds.get(stateId);
|
|
4687
|
+
if (kind === undefined)
|
|
4688
|
+
return undefined;
|
|
4689
|
+
return {
|
|
4690
|
+
stateId,
|
|
4691
|
+
kind,
|
|
4692
|
+
...(description === undefined ? {} : { description }),
|
|
4693
|
+
};
|
|
4694
|
+
}
|
|
4632
4695
|
function receiptTracePayload(receipt) {
|
|
4633
4696
|
return {
|
|
4634
4697
|
disposition: receipt.disposition,
|
|
@@ -4916,6 +4979,15 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
4916
4979
|
throw new Error(`${label} deferred continuation requires repository.runDeferred`);
|
|
4917
4980
|
}
|
|
4918
4981
|
const effectBoundary = continuationBoundarySeed(operation, turnId);
|
|
4982
|
+
const boundPlayerId = resolvedPlayerId(effectBoundary.roleId);
|
|
4983
|
+
const validateBinding = (value) => {
|
|
4984
|
+
if (!isPlainObject(value) || Object.keys(value).length !== 2 ||
|
|
4985
|
+
value.v !== 1 ||
|
|
4986
|
+
value.playerId !== (boundPlayerId ?? effectBoundary.roleId)) {
|
|
4987
|
+
throw new TypeError(`${label} bound deferred player continuation is invalid`);
|
|
4988
|
+
}
|
|
4989
|
+
};
|
|
4990
|
+
validateBinding(operation.playerContinuation);
|
|
4919
4991
|
const continuation = {
|
|
4920
4992
|
operationId: operation.operationId,
|
|
4921
4993
|
effectBoundary,
|
|
@@ -4941,18 +5013,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
4941
5013
|
operationId: operation.operationId,
|
|
4942
5014
|
effectBoundary,
|
|
4943
5015
|
operation: async ({ playerContinuation }) => {
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
: selectPlayerResume(effectBoundary.roleId, resolvedPlayerId(effectBoundary.roleId));
|
|
4947
|
-
if (selectedContinuation !== false &&
|
|
4948
|
-
(typeof selectedContinuation !== 'string' ||
|
|
4949
|
-
selectedContinuation.trim().length === 0)) {
|
|
4950
|
-
throw new TypeError(`${label} bound deferred player continuation is invalid`);
|
|
4951
|
-
}
|
|
4952
|
-
// Retained adoption owns a fresh Captain-session player ledger;
|
|
4953
|
-
// no source token becomes target ownership. Same-engagement
|
|
4954
|
-
// continuation still uses the exact durable binding.
|
|
4955
|
-
continuation.playerContinuation = selectedContinuation;
|
|
5016
|
+
validateBinding(playerContinuation);
|
|
5017
|
+
continuation.playerContinuation = selectPlayerResume(effectBoundary.roleId, boundPlayerId);
|
|
4956
5018
|
continuationStarted = true;
|
|
4957
5019
|
actor.send(event);
|
|
4958
5020
|
// The host has durably started this boundary and the FSM has
|
|
@@ -71,6 +71,7 @@ import type {
|
|
|
71
71
|
PlaybookSession,
|
|
72
72
|
PlaybookState,
|
|
73
73
|
PlaybookSuspendedCall,
|
|
74
|
+
PlaybookTerminalOutcome,
|
|
74
75
|
PlaybookTraceEvent,
|
|
75
76
|
PlaybookTraceType,
|
|
76
77
|
PlayerResult,
|
|
@@ -1980,6 +1981,50 @@ export function stateDescriptionsFromMachine(
|
|
|
1980
1981
|
return descriptions;
|
|
1981
1982
|
}
|
|
1982
1983
|
|
|
1984
|
+
/**
|
|
1985
|
+
* DR-048: each root final state's declared terminal kind, read from
|
|
1986
|
+
* `meta.playbook.terminal` in `machine.config`. The kind is compiled
|
|
1987
|
+
* metadata — the compiler derives it from the Source's own outcome wording,
|
|
1988
|
+
* exactly as it derives the state's description — so a caller learns whether
|
|
1989
|
+
* a completed child succeeded from the machine it reached, never from the
|
|
1990
|
+
* child's output fields or an agent's prose.
|
|
1991
|
+
*
|
|
1992
|
+
* A machine whose final states declare no kind yields an empty map and keeps
|
|
1993
|
+
* the pre-DR-048 delivery. A `terminal` on a non-final state, or a value
|
|
1994
|
+
* other than `success` or `failure`, is a malformed artifact and throws.
|
|
1995
|
+
*/
|
|
1996
|
+
export function terminalOutcomesFromMachine(
|
|
1997
|
+
machine: AnyStateMachine,
|
|
1998
|
+
label = 'playbook',
|
|
1999
|
+
): ReadonlyMap<string, 'success' | 'failure'> {
|
|
2000
|
+
const kinds = new Map<string, 'success' | 'failure'>();
|
|
2001
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
2002
|
+
if (!isPlainObject(config) || !isPlainObject(config.states)) return kinds;
|
|
2003
|
+
for (const [key, stateDef] of Object.entries(config.states)) {
|
|
2004
|
+
if (!isPlainObject(stateDef)) continue;
|
|
2005
|
+
const playbook = isPlainObject(stateDef.meta)
|
|
2006
|
+
? (stateDef.meta as Record<string, unknown>).playbook
|
|
2007
|
+
: undefined;
|
|
2008
|
+
const declared = isPlainObject(playbook) ? playbook.terminal : undefined;
|
|
2009
|
+
if (declared === undefined) continue;
|
|
2010
|
+
if (declared !== 'success' && declared !== 'failure') {
|
|
2011
|
+
throw new TypeError(
|
|
2012
|
+
`${label} state ${key} declares meta.playbook.terminal ` +
|
|
2013
|
+
`${JSON.stringify(declared)}; only 'success' or 'failure' is a ` +
|
|
2014
|
+
'terminal kind',
|
|
2015
|
+
);
|
|
2016
|
+
}
|
|
2017
|
+
if (stateDef.type !== 'final') {
|
|
2018
|
+
throw new TypeError(
|
|
2019
|
+
`${label} state ${key} declares meta.playbook.terminal but is not ` +
|
|
2020
|
+
'a final state',
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
kinds.set(key, declared);
|
|
2024
|
+
}
|
|
2025
|
+
return kinds;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1983
2028
|
/**
|
|
1984
2029
|
* First configured target of `eventType` from the state with `stateId`,
|
|
1985
2030
|
* falling back to the machine root's own transitions. Used only to pick the
|
|
@@ -3075,6 +3120,10 @@ export function createXStatePlaybookRuntime<
|
|
|
3075
3120
|
// DR-029: source state descriptions label the control actions the
|
|
3076
3121
|
// runtime advertises through `describe()`.
|
|
3077
3122
|
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
3123
|
+
// DR-048: a malformed terminal declaration is a control-plane defect of the
|
|
3124
|
+
// artifact, so it fails construction rather than at the one run that
|
|
3125
|
+
// happens to reach that final state.
|
|
3126
|
+
const terminalKinds = terminalOutcomesFromMachine(machine, label);
|
|
3078
3127
|
const roleStatesDescriptor = specDescriptors.roleStates;
|
|
3079
3128
|
if (
|
|
3080
3129
|
roleStatesDescriptor !== undefined &&
|
|
@@ -4474,7 +4523,7 @@ export function createXStatePlaybookRuntime<
|
|
|
4474
4523
|
playerId: string | undefined,
|
|
4475
4524
|
): JsonValue {
|
|
4476
4525
|
return snapshotJsonValue(
|
|
4477
|
-
|
|
4526
|
+
{ v: 1, playerId: playerId ?? roleId },
|
|
4478
4527
|
`${label} deferred player continuation`,
|
|
4479
4528
|
);
|
|
4480
4529
|
}
|
|
@@ -6477,10 +6526,19 @@ export function createXStatePlaybookRuntime<
|
|
|
6477
6526
|
!hasUnresolvedReconciliation()
|
|
6478
6527
|
? stateDescriptionFor(state)
|
|
6479
6528
|
: undefined;
|
|
6529
|
+
// DR-048: the reached final state's compiled terminal meaning, read
|
|
6530
|
+
// from the artifact. It is withheld exactly when the published
|
|
6531
|
+
// description is, so an unresolved reconciliation publishes no
|
|
6532
|
+
// terminal meaning at all.
|
|
6533
|
+
const terminal =
|
|
6534
|
+
hasUnresolvedReconciliation() || state.stateId === undefined
|
|
6535
|
+
? undefined
|
|
6536
|
+
: terminalOutcomeFor(state.stateId, stateDescription);
|
|
6480
6537
|
return {
|
|
6481
6538
|
outcome,
|
|
6482
6539
|
state,
|
|
6483
6540
|
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
6541
|
+
...(terminal === undefined ? {} : { terminal }),
|
|
6484
6542
|
...(output === undefined
|
|
6485
6543
|
? {}
|
|
6486
6544
|
: {
|
|
@@ -6867,6 +6925,21 @@ export function createXStatePlaybookRuntime<
|
|
|
6867
6925
|
return undefined;
|
|
6868
6926
|
}
|
|
6869
6927
|
|
|
6928
|
+
// DR-048: the public terminal record for a reached final state, present
|
|
6929
|
+
// only for an artifact that declares that state's kind.
|
|
6930
|
+
function terminalOutcomeFor(
|
|
6931
|
+
stateId: string,
|
|
6932
|
+
description: string | undefined,
|
|
6933
|
+
): PlaybookTerminalOutcome | undefined {
|
|
6934
|
+
const kind = terminalKinds.get(stateId);
|
|
6935
|
+
if (kind === undefined) return undefined;
|
|
6936
|
+
return {
|
|
6937
|
+
stateId,
|
|
6938
|
+
kind,
|
|
6939
|
+
...(description === undefined ? {} : { description }),
|
|
6940
|
+
};
|
|
6941
|
+
}
|
|
6942
|
+
|
|
6870
6943
|
function receiptTracePayload(
|
|
6871
6944
|
receipt: PlaybookControlReceipt,
|
|
6872
6945
|
): Record<string, unknown> {
|
|
@@ -7251,6 +7324,15 @@ export function createXStatePlaybookRuntime<
|
|
|
7251
7324
|
);
|
|
7252
7325
|
}
|
|
7253
7326
|
const effectBoundary = continuationBoundarySeed(operation, turnId);
|
|
7327
|
+
const boundPlayerId = resolvedPlayerId(effectBoundary.roleId);
|
|
7328
|
+
const validateBinding = (value: unknown): void => {
|
|
7329
|
+
if (!isPlainObject(value) || Object.keys(value).length !== 2 ||
|
|
7330
|
+
value.v !== 1 ||
|
|
7331
|
+
value.playerId !== (boundPlayerId ?? effectBoundary.roleId)) {
|
|
7332
|
+
throw new TypeError(`${label} bound deferred player continuation is invalid`);
|
|
7333
|
+
}
|
|
7334
|
+
};
|
|
7335
|
+
validateBinding(operation.playerContinuation);
|
|
7254
7336
|
const continuation: NonNullable<typeof activeDeferredContinuation> = {
|
|
7255
7337
|
operationId: operation.operationId,
|
|
7256
7338
|
effectBoundary,
|
|
@@ -7276,26 +7358,10 @@ export function createXStatePlaybookRuntime<
|
|
|
7276
7358
|
operationId: operation.operationId,
|
|
7277
7359
|
effectBoundary,
|
|
7278
7360
|
operation: async ({ playerContinuation }) => {
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
effectBoundary.roleId,
|
|
7284
|
-
resolvedPlayerId(effectBoundary.roleId),
|
|
7285
|
-
);
|
|
7286
|
-
if (
|
|
7287
|
-
selectedContinuation !== false &&
|
|
7288
|
-
(typeof selectedContinuation !== 'string' ||
|
|
7289
|
-
selectedContinuation.trim().length === 0)
|
|
7290
|
-
) {
|
|
7291
|
-
throw new TypeError(
|
|
7292
|
-
`${label} bound deferred player continuation is invalid`,
|
|
7293
|
-
);
|
|
7294
|
-
}
|
|
7295
|
-
// Retained adoption owns a fresh Captain-session player ledger;
|
|
7296
|
-
// no source token becomes target ownership. Same-engagement
|
|
7297
|
-
// continuation still uses the exact durable binding.
|
|
7298
|
-
continuation.playerContinuation = selectedContinuation;
|
|
7361
|
+
validateBinding(playerContinuation);
|
|
7362
|
+
continuation.playerContinuation = selectPlayerResume(
|
|
7363
|
+
effectBoundary.roleId, boundPlayerId,
|
|
7364
|
+
);
|
|
7299
7365
|
continuationStarted = true;
|
|
7300
7366
|
actor!.send(event);
|
|
7301
7367
|
// The host has durably started this boundary and the FSM has
|
package/src/xstate-runtime.js
CHANGED
|
@@ -1617,7 +1617,13 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
|
|
|
1617
1617
|
export class NestedPlaybookCallError extends Error {
|
|
1618
1618
|
result;
|
|
1619
1619
|
constructor(result) {
|
|
1620
|
-
|
|
1620
|
+
// DR-048: a completed child that reached an authored failure terminal is
|
|
1621
|
+
// rejected through the same error path as an abort or an error, so its
|
|
1622
|
+
// message names that final state rather than reporting `ok`.
|
|
1623
|
+
const fallback = result.status === 'ok'
|
|
1624
|
+
? `Child playbook ${result.playbookId} reached failure terminal ` +
|
|
1625
|
+
`${result.terminal?.stateId ?? 'unknown'}`
|
|
1626
|
+
: `Child playbook ${result.playbookId} ${result.status}`;
|
|
1621
1627
|
const normalized = result.status === 'ok' ? undefined : result.error;
|
|
1622
1628
|
super(normalized?.message ?? fallback);
|
|
1623
1629
|
this.name = normalized?.name ?? 'NestedPlaybookCallError';
|
|
@@ -1723,6 +1729,26 @@ function validateNormalizedError(error, path) {
|
|
|
1723
1729
|
throw new TypeError(`${path}.stack must be a string`);
|
|
1724
1730
|
}
|
|
1725
1731
|
}
|
|
1732
|
+
// DR-048: the completed child's compiled terminal record. It is runtime-owned
|
|
1733
|
+
// data read from the child's artifact, so a malformed one is a control-plane
|
|
1734
|
+
// error rather than an authored child outcome.
|
|
1735
|
+
function validateTerminalOutcome(value) {
|
|
1736
|
+
if (!isRecord(value)) {
|
|
1737
|
+
throw new TypeError('playbook result terminal must be an object');
|
|
1738
|
+
}
|
|
1739
|
+
rejectUnknownKeys(value, ['stateId', 'kind', 'description'], 'playbook result terminal');
|
|
1740
|
+
requireNonEmptyString(value.stateId, 'playbook result terminal stateId');
|
|
1741
|
+
if (value.kind !== 'success' && value.kind !== 'failure') {
|
|
1742
|
+
throw new TypeError("playbook result terminal kind must be 'success' or 'failure'");
|
|
1743
|
+
}
|
|
1744
|
+
if (own(value, 'description') && typeof value.description !== 'string') {
|
|
1745
|
+
throw new TypeError('playbook result terminal description must be a string');
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
/** DR-048: a completed child that reached an authored failure terminal. */
|
|
1749
|
+
function isFailureTerminal(result) {
|
|
1750
|
+
return result.status === 'ok' && result.terminal?.kind === 'failure';
|
|
1751
|
+
}
|
|
1726
1752
|
export function validatePlaybookCallResult(result, expectedPlaybookId, expectedChildSessionId) {
|
|
1727
1753
|
const capturedResult = snapshotJsonValue(result, 'playbook result');
|
|
1728
1754
|
if (!isRecord(capturedResult)) {
|
|
@@ -1737,8 +1763,11 @@ export function validatePlaybookCallResult(result, expectedPlaybookId, expectedC
|
|
|
1737
1763
|
throw new PlaybookCallIdentityError(`playbook result target ${String(capturedResult.playbookId)} does not match ${expectedPlaybookId}`);
|
|
1738
1764
|
}
|
|
1739
1765
|
if (capturedResult.status === 'ok') {
|
|
1740
|
-
rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'output'], 'playbook result');
|
|
1766
|
+
rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'output', 'terminal'], 'playbook result');
|
|
1741
1767
|
requireNonEmptyString(capturedResult.childSessionId, 'playbook result childSessionId');
|
|
1768
|
+
if (own(capturedResult, 'terminal')) {
|
|
1769
|
+
validateTerminalOutcome(capturedResult.terminal);
|
|
1770
|
+
}
|
|
1742
1771
|
}
|
|
1743
1772
|
else {
|
|
1744
1773
|
rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'error'], 'playbook result');
|
|
@@ -1832,8 +1861,9 @@ function resultFromThrown(playbookId, childSessionId, error, aborted) {
|
|
|
1832
1861
|
return snapshotJsonValue(result, 'playbook result');
|
|
1833
1862
|
}
|
|
1834
1863
|
function outputOrThrow(result) {
|
|
1835
|
-
if (result.status === 'ok')
|
|
1864
|
+
if (result.status === 'ok' && !isFailureTerminal(result)) {
|
|
1836
1865
|
return result.output;
|
|
1866
|
+
}
|
|
1837
1867
|
throw new NestedPlaybookCallError(result);
|
|
1838
1868
|
}
|
|
1839
1869
|
export function createNestedPlaybookBridge(options) {
|
|
@@ -2014,7 +2044,8 @@ export function createNestedPlaybookBridge(options) {
|
|
|
2014
2044
|
else if (cleanupControlError !== undefined) {
|
|
2015
2045
|
active.deferred.reject(cleanupControlError);
|
|
2016
2046
|
}
|
|
2017
|
-
else if (effectiveResult.status === 'ok'
|
|
2047
|
+
else if (effectiveResult.status === 'ok' &&
|
|
2048
|
+
!isFailureTerminal(effectiveResult)) {
|
|
2018
2049
|
active.deferred.resolve(effectiveResult.output);
|
|
2019
2050
|
}
|
|
2020
2051
|
else {
|
package/src/xstate-runtime.ts
CHANGED
|
@@ -2596,7 +2596,14 @@ export class NestedPlaybookCallError extends Error {
|
|
|
2596
2596
|
readonly result: PlaybookCallResult;
|
|
2597
2597
|
|
|
2598
2598
|
constructor(result: PlaybookCallResult) {
|
|
2599
|
-
|
|
2599
|
+
// DR-048: a completed child that reached an authored failure terminal is
|
|
2600
|
+
// rejected through the same error path as an abort or an error, so its
|
|
2601
|
+
// message names that final state rather than reporting `ok`.
|
|
2602
|
+
const fallback =
|
|
2603
|
+
result.status === 'ok'
|
|
2604
|
+
? `Child playbook ${result.playbookId} reached failure terminal ` +
|
|
2605
|
+
`${result.terminal?.stateId ?? 'unknown'}`
|
|
2606
|
+
: `Child playbook ${result.playbookId} ${result.status}`;
|
|
2600
2607
|
const normalized = result.status === 'ok' ? undefined : result.error;
|
|
2601
2608
|
super(normalized?.message ?? fallback);
|
|
2602
2609
|
this.name = normalized?.name ?? 'NestedPlaybookCallError';
|
|
@@ -2794,6 +2801,36 @@ function validateNormalizedError(error: unknown, path: string): void {
|
|
|
2794
2801
|
}
|
|
2795
2802
|
}
|
|
2796
2803
|
|
|
2804
|
+
// DR-048: the completed child's compiled terminal record. It is runtime-owned
|
|
2805
|
+
// data read from the child's artifact, so a malformed one is a control-plane
|
|
2806
|
+
// error rather than an authored child outcome.
|
|
2807
|
+
function validateTerminalOutcome(value: unknown): void {
|
|
2808
|
+
if (!isRecord(value)) {
|
|
2809
|
+
throw new TypeError('playbook result terminal must be an object');
|
|
2810
|
+
}
|
|
2811
|
+
rejectUnknownKeys(
|
|
2812
|
+
value,
|
|
2813
|
+
['stateId', 'kind', 'description'],
|
|
2814
|
+
'playbook result terminal',
|
|
2815
|
+
);
|
|
2816
|
+
requireNonEmptyString(value.stateId, 'playbook result terminal stateId');
|
|
2817
|
+
if (value.kind !== 'success' && value.kind !== 'failure') {
|
|
2818
|
+
throw new TypeError(
|
|
2819
|
+
"playbook result terminal kind must be 'success' or 'failure'",
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
if (own(value, 'description') && typeof value.description !== 'string') {
|
|
2823
|
+
throw new TypeError(
|
|
2824
|
+
'playbook result terminal description must be a string',
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
/** DR-048: a completed child that reached an authored failure terminal. */
|
|
2830
|
+
function isFailureTerminal(result: PlaybookCallResult): boolean {
|
|
2831
|
+
return result.status === 'ok' && result.terminal?.kind === 'failure';
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2797
2834
|
export function validatePlaybookCallResult(
|
|
2798
2835
|
result: unknown,
|
|
2799
2836
|
expectedPlaybookId: string,
|
|
@@ -2818,13 +2855,16 @@ export function validatePlaybookCallResult(
|
|
|
2818
2855
|
if (capturedResult.status === 'ok') {
|
|
2819
2856
|
rejectUnknownKeys(
|
|
2820
2857
|
capturedResult,
|
|
2821
|
-
['status', 'playbookId', 'childSessionId', 'state', 'output'],
|
|
2858
|
+
['status', 'playbookId', 'childSessionId', 'state', 'output', 'terminal'],
|
|
2822
2859
|
'playbook result',
|
|
2823
2860
|
);
|
|
2824
2861
|
requireNonEmptyString(
|
|
2825
2862
|
capturedResult.childSessionId,
|
|
2826
2863
|
'playbook result childSessionId',
|
|
2827
2864
|
);
|
|
2865
|
+
if (own(capturedResult, 'terminal')) {
|
|
2866
|
+
validateTerminalOutcome(capturedResult.terminal);
|
|
2867
|
+
}
|
|
2828
2868
|
} else {
|
|
2829
2869
|
rejectUnknownKeys(
|
|
2830
2870
|
capturedResult,
|
|
@@ -2973,7 +3013,9 @@ function resultFromThrown(
|
|
|
2973
3013
|
}
|
|
2974
3014
|
|
|
2975
3015
|
function outputOrThrow(result: PlaybookCallResult): JsonValue | undefined {
|
|
2976
|
-
if (result.status === 'ok'
|
|
3016
|
+
if (result.status === 'ok' && !isFailureTerminal(result)) {
|
|
3017
|
+
return result.output;
|
|
3018
|
+
}
|
|
2977
3019
|
throw new NestedPlaybookCallError(result);
|
|
2978
3020
|
}
|
|
2979
3021
|
|
|
@@ -3206,7 +3248,10 @@ export function createNestedPlaybookBridge<
|
|
|
3206
3248
|
active.deferred.reject(controlError);
|
|
3207
3249
|
} else if (cleanupControlError !== undefined) {
|
|
3208
3250
|
active.deferred.reject(cleanupControlError);
|
|
3209
|
-
} else if (
|
|
3251
|
+
} else if (
|
|
3252
|
+
effectiveResult.status === 'ok' &&
|
|
3253
|
+
!isFailureTerminal(effectiveResult)
|
|
3254
|
+
) {
|
|
3210
3255
|
active.deferred.resolve(effectiveResult.output);
|
|
3211
3256
|
} else {
|
|
3212
3257
|
active.deferred.reject(new NestedPlaybookCallError(effectiveResult));
|