@sublang/playbook 12.2.2 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 &&
@@ -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> {
@@ -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
- const fallback = `Child playbook ${result.playbookId} ${result.status}`;
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 {
@@ -2596,7 +2596,14 @@ export class NestedPlaybookCallError extends Error {
2596
2596
  readonly result: PlaybookCallResult;
2597
2597
 
2598
2598
  constructor(result: PlaybookCallResult) {
2599
- const fallback = `Child playbook ${result.playbookId} ${result.status}`;
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') return result.output;
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 (effectiveResult.status === 'ok') {
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));