@uipath/maestro-builder-sdk 6.8.3 → 6.8.4

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.
@@ -564,16 +564,16 @@ export interface HitlInputs {
564
564
  /**
565
565
  * The completion buttons, e.g. `['Approve', 'Reject']`. At least one, or the
566
566
  * reviewer has no way to complete the task and the flow waits forever. The
567
- * first is the primary (default) one. MORE THAN ONE gives each outcome its own
568
- * exit, `outcome-<slug>`: the first continues the main path and the rest are
569
- * routed with `.stepToList('outcome-<slug>', …)`. Only
570
- * {@link HitlInputs.outcomePorts | outcomePorts}`: false`, a `variant`, an
571
- * explicit `{ version }`, or every outcome ending the process keeps the older
572
- * single `completed` exit.
567
+ * first is the primary (default) one. Each outcome is its own exit,
568
+ * `outcome-<slug>`: the default node and `'quick-form'` never leave on
569
+ * `completed` once they have outcomes. On the default node MORE THAN ONE
570
+ * outcome routes per outcome: the first continues the main path and the rest
571
+ * are routed with `.stepToList('outcome-<slug>', …)`. With `'quick-form'`,
572
+ * {@link HitlInputs.outcomePorts | outcomePorts}`: false`, an explicit
573
+ * `{ version }` or one outcome, `.step()` continues EVERY outcome instead.
573
574
  *
574
575
  * Which one they pressed is also `out('<step>', 'Action')` — that is what the
575
- * platform's own routing conditions compare against, so a task on the single
576
- * exit branches on it downstream.
576
+ * platform's own routing conditions compare against.
577
577
  */
578
578
  outcomes: Outcome[];
579
579
  /** Who gets the task and how. Omit for the definition's default delivery. */
@@ -581,25 +581,25 @@ export interface HitlInputs {
581
581
  /** Action Center labels, comma-separated (the platform stores one string). */
582
582
  labels?: string;
583
583
  /**
584
- * Whether each outcome exits from its OWN port (`outcome-<id>`) instead of the
585
- * single `completed` one. **More than one outcome does this by default**, so
586
- * the useful value is `false` — an opt back into the single exit, for a task
587
- * that routes the decision as DATA by reading `out('<step>', 'Action')`.
584
+ * Whether the task ROUTES per outcome. Each outcome has its own exit either
585
+ * way (`outcome-<id>`, the name slugified: `'Approve'` → `outcome-approve`).
588
586
  *
589
- * Per-outcome exits select the node's **1.1** definition, whose handles are
590
- * `outcome-<id>` (ids are the outcome names slugified, e.g. `'Approve'` →
591
- * `outcome-approve`). They REPLACE `completed` rather than adding to it. The
592
- * FIRST (primary) outcome continues the main path; route the others with
593
- * `.stepToList('outcome-<id>', …)`, or the run stalls when the reviewer picks
594
- * one. Base variant only — the sub-typed nodes have no per-outcome definition
595
- * version, so they keep the single exit whatever this says.
587
+ * `true`: the FIRST outcome continues the main path and the others are routed
588
+ * with `.stepToList('outcome-<id>', …)`, or the run stalls when the reviewer
589
+ * picks one. The default node with more than one outcome already does this,
590
+ * on its **1.1** definition unless a `{ version }` pins another; `'quick-form'`
591
+ * supports it on its one definition. `false`: EVERY outcome continues to the
592
+ * next step on its own edge — a task that routes the decision as DATA by
593
+ * reading `out('<step>', 'Action')`; `'quick-form'` does this by default.
594
+ * `'action-app'` and `'document-validation'` read their outcomes from the app
595
+ * and leave on their one `completed` exit, so `true` is refused there.
596
596
  */
597
597
  outcomePorts?: boolean;
598
598
  /**
599
599
  * Expose the task's runtime error as a bindable `out('<step>', 'error')`
600
600
  * object (`{ code, message, detail, category, status }`). Selects the node's
601
601
  * **1.2** definition — which also routes per outcome, so this implies
602
- * {@link HitlInputs.outcomePorts | outcomePorts}. Base variant only.
602
+ * {@link HitlInputs.outcomePorts | outcomePorts}. Default node only.
603
603
  */
604
604
  exposeError?: boolean;
605
605
  /**
@@ -1,38 +1,73 @@
1
1
  /**
2
- * Does a human task route PER OUTCOME — one `outcome-<id>` exit each — or leave
3
- * on the single `completed` handle?
4
- *
5
- * Shared by `serialize` (which picks the node's definition version and its tail
6
- * port from this) and `check` (whose four outcome-port rules all key on it), so
7
- * the two cannot drift. They did drift once, in the direction that matters
8
- * least visibly: `check` keyed on the `outcomePorts` FLAG while `serialize` was
9
- * about to infer it, which would have made `check` refuse per-outcome wiring
10
- * that `compile` then emitted.
11
- *
12
- * **More than one outcome infers it.** Declaring outcomes creates no exits on
13
- * the 1.0 definition — its one source handle is `completed`, so every button
14
- * the reviewer can press leaves the node the same way and the decision never
15
- * reaches the graph. That shape is only meaningful for a zero- or one-outcome
16
- * acknowledgement, and at two or more it compiled, validated `Valid`, and
17
- * shipped flows whose approve and reject paths were indistinguishable — first
18
- * silently (flow-builder-sdk#735), then past the warning that #738 added
19
- * (#739). So the flag is what an author reaches for to *override* the shape,
20
- * not what they must remember to switch it on.
21
- *
22
- * Four things turn the inference off, in this order:
23
- *
24
- * 1. `outcomePorts: false` — an explicit opt back into the single exit.
25
- * 2. A `variant`. The quick-form, action-app and document-validation node types
26
- * carry no per-outcome definition version to select, so `completed` plus a
27
- * `switch` on `out('<step>', 'Action')` is their only shape (#729).
28
- * 3. An explicitly pinned `{ version }`. The shape of a deployed artifact is
29
- * recorded in its `typeVersion`, and `decompile` emits that pin — so
30
- * decompiling a 1.0 flow and recompiling it round-trips exactly instead of
31
- * promoting the node to 1.1 and stranding its `completed` edge.
32
- * 4. Every outcome carrying `action: 'End'`. The run stops AT the node, so no
33
- * exit can distinguish anything and per-outcome handles would only invite
34
- * routes that can never be taken. The same reasoning suppresses
35
- * HITL_OUTCOMES_UNREACHABLE.
2
+ * How a human task leaves its node — the one model `serialize`, `check` and
3
+ * `.stepSwitch` (`step-ports.ts`) all read, so the three cannot drift. They did
4
+ * drift once, in the direction that matters least visibly: `check` keyed on the
5
+ * `outcomePorts` FLAG while `serialize` was about to infer it, which would have
6
+ * made `check` refuse per-outcome wiring that `compile` then emitted.
7
+ *
8
+ * **The product's rule, which the model follows.** On the base node
9
+ * (`uipath.human-in-the-loop`, `type: 'quick'`) and on the quick-form variant,
10
+ * the designer draws ONE handle per entry in `inputs.schema.outcomes`, named
11
+ * `outcome-<id>`, whatever the node's `typeVersion` — and draws the manifest's
12
+ * static `completed` handle only when there are no outcomes
13
+ * (flow-workbench `build-handle-customization.ts:102-124`; it derives them on
14
+ * every render and deliberately never saves them). A leftover `completed` edge
15
+ * on such a node is a stale placeholder: since flow-workbench #3512 the load
16
+ * path and the converter remap it to the PRIMARY outcome
17
+ * (`hitl-edge-reconciliation.ts:52-63`), so every other outcome loses its path at
18
+ * run time. So a task with outcomes never leaves on `completed` here. The two
19
+ * app-backed variants are different: action-app and document-validation read
20
+ * their outcomes from `inputs.app`, and `completed` really is the only handle
21
+ * they render (`hitl-edge-reconciliation.ts:68-74`).
22
+ *
23
+ * (This replaces an older premise — "the definition VERSION selects the port
24
+ * shape, and the variants have no per-outcome version" (#729). The manifests do
25
+ * declare only `completed` statically; the designer just never reads that list
26
+ * for these two node types once outcomes exist.)
27
+ *
28
+ * **Three shapes**, and what `.step()` after the task means in each — ".step()
29
+ * continues from the task's tails":
30
+ *
31
+ * - `per-outcome`: the task ROUTES per outcome. One tail, the PRIMARY (first)
32
+ * outcome's `outcome-<id>`; the author routes the rest with `.stepToList` /
33
+ * `.stepToRef`, or all of them as `.stepSwitch` arms.
34
+ * - `every-outcome`: the task does NOT route per outcome. One tail PER outcome,
35
+ * so `.step()` continues every outcome to the next step ("fan-out") — the
36
+ * decision reaches the flow as data, `out('<step>', 'Action')`, in the handles
37
+ * the product draws. An `action: 'End'` outcome is the exception: the flow
38
+ * runtime never reads `action` (flow-workbench `hitl-serialization.ts` does
39
+ * not; only the agent storage projection does), so the graph is the only thing
40
+ * that can end the run there, and `serialize` gives it an End node.
41
+ * - `placeholder`: `completed` — action-app, document-validation, and a task
42
+ * with no outcomes.
43
+ *
44
+ * **More than one outcome infers `per-outcome`** on the base node (#739): a task
45
+ * that publishes a choice and then routes every button the same way was the
46
+ * modelling error behind flow-builder-sdk#735, and a warning alone did not
47
+ * change what agents emitted. Five things keep a base or quick-form task on
48
+ * `every-outcome` instead, in this order:
49
+ *
50
+ * 1. `outcomePorts: false` — the explicit "decision as data" choice.
51
+ * 2. `variant: 'quick-form'`. Not a product limit — quick-form draws the same
52
+ * per-outcome handles — but its authors write `.step()` or a `.switch` on the
53
+ * Action after it and mean "every outcome continues" (every quick-form source
54
+ * in the 09-23 baseline does), so inferring primary-only there would strand
55
+ * the other outcomes. It routes per outcome when asked: `outcomePorts: true`,
56
+ * or `.stepSwitch`. Its definition version never moves.
57
+ * 3. An explicit `{ version }` pin. `decompile` pins every node and writes
58
+ * `outcomePorts: true` whenever a task's outcomes lead to different places, so
59
+ * a pinned task that reaches `.step()` without the flag is a round-tripped
60
+ * artifact whose outcomes all go to one step, or a legacy lone `completed`
61
+ * edge, which meant "every outcome continues" before #3512. Both are this
62
+ * shape. The pin also wins the version: `outcomePorts: true` on a pinned 1.0
63
+ * routes per outcome on the 1.0 node, which `flow validate` accepts.
64
+ * 4. A single outcome — an acknowledgement, not a choice.
65
+ * 5. Every outcome carrying `action: 'End'` — there is no continuation to
66
+ * distinguish, and each outcome gets its End.
67
+ *
68
+ * `exposeError: true` or `outcomePorts: true` select `per-outcome` on every node
69
+ * type, including the two app-backed variants, so that `serialize` and `check`
70
+ * refuse them there with their own message rather than silently ignoring them.
36
71
  */
37
72
  import type { HitlInputs } from './actions.js';
38
73
  /**
@@ -44,4 +79,64 @@ import type { HitlInputs } from './actions.js';
44
79
  * nothing else recognises.
45
80
  */
46
81
  export declare function outcomeSlug(name: unknown, index: number): string;
82
+ /** One outcome's exit. */
83
+ export interface HitlOutcomeExit {
84
+ /** The outcome NAME — what `.stepSwitch` arms and `out('<step>', 'Action')` compare against. */
85
+ value: string;
86
+ /** The emitted source handle, `outcome-<id>`. */
87
+ port: string;
88
+ /** `action: 'End'`: the author says this outcome ends the run. */
89
+ endsProcess: boolean;
90
+ }
91
+ /** Why a base or quick-form task continues every outcome, phrased for a diagnostic. */
92
+ export type HitlFanOutReason = 'outcomePorts: false' | 'quick-form' | 'pinned' | 'one outcome' | 'every outcome ends';
93
+ export type HitlExitModel = {
94
+ shape: 'placeholder';
95
+ } | {
96
+ shape: 'every-outcome';
97
+ exits: HitlOutcomeExit[];
98
+ why: HitlFanOutReason;
99
+ } | {
100
+ shape: 'per-outcome';
101
+ exits: HitlOutcomeExit[];
102
+ };
103
+ /**
104
+ * The two human-task node types whose exits the product derives from
105
+ * `inputs.schema.outcomes`. Keyed on TYPE, never on whether `schema.outcomes` is
106
+ * present: the SDK writes `schema.outcomes` on an action-app task too (its
107
+ * `Action` output is enumerated over them), and that task's `completed` is real.
108
+ */
109
+ export declare const OUTCOME_HANDLE_NODE_TYPES: ReadonlySet<string>;
110
+ /** The same test on the authored side: no variant (the base node), or quick-form. */
111
+ export declare function hitlDerivesOutcomeHandles(variant: unknown): boolean;
112
+ export declare function hitlExitModel(inputs: HitlInputs | undefined, pinnedVersion?: string): HitlExitModel;
113
+ /** Does this task route PER OUTCOME (primary continues, the rest are routed)? */
47
114
  export declare function hitlRoutesPerOutcome(inputs: HitlInputs | undefined, pinnedVersion?: string): boolean;
115
+ type NodeLike = {
116
+ id?: unknown;
117
+ type?: unknown;
118
+ inputs?: any;
119
+ };
120
+ type EdgeLike = {
121
+ sourceNodeId?: unknown;
122
+ sourcePort?: unknown;
123
+ };
124
+ /**
125
+ * Why an edge on `port` cannot leave `node`, or `undefined` when it can.
126
+ *
127
+ * Only the two node types whose handles the product derives from the outcomes
128
+ * are judged, and only once they HAVE outcomes: then `completed` is a handle
129
+ * the product does not draw, and an `outcome-<id>` naming no declared outcome is
130
+ * one `flow validate` refuses ("Edge references undeclared source handle"). A
131
+ * real outcome named "Completed" is `outcome-completed`, which is fine — the
132
+ * bare `completed` never is.
133
+ */
134
+ export declare function hitlExitViolation(node: NodeLike, port: string): string | undefined;
135
+ /**
136
+ * The post-emit half of the guard: every edge of an emitted scope, whatever
137
+ * path produced it (a tail, a `.stepToList` seed, a parked `.stepToRef`), is held
138
+ * to {@link hitlExitViolation}. Fails closed — a serializer that emitted one of
139
+ * these would ship a flow that validates and then drops outcomes at run time.
140
+ */
141
+ export declare function assertHitlExitInvariant(nodes: readonly NodeLike[], edges: readonly EdgeLike[]): void;
142
+ export {};
@@ -14,20 +14,104 @@ export function outcomeSlug(name, index) {
14
14
  .replace(/^-+|-+$/g, '');
15
15
  return slug || `outcome-${index + 1}`;
16
16
  }
17
- export function hitlRoutesPerOutcome(inputs, pinnedVersion) {
18
- // `exposeError` selects 1.2, which routes per outcome too. Both flags are
19
- // honoured on a variant so that `serialize` still refuses them there with its
20
- // own message rather than silently ignoring them.
17
+ /**
18
+ * The two human-task node types whose exits the product derives from
19
+ * `inputs.schema.outcomes`. Keyed on TYPE, never on whether `schema.outcomes` is
20
+ * present: the SDK writes `schema.outcomes` on an action-app task too (its
21
+ * `Action` output is enumerated over them), and that task's `completed` is real.
22
+ */
23
+ export const OUTCOME_HANDLE_NODE_TYPES = new Set([
24
+ 'uipath.human-in-the-loop',
25
+ 'uipath.human-in-the-loop.quick-form',
26
+ ]);
27
+ /** The same test on the authored side: no variant (the base node), or quick-form. */
28
+ export function hitlDerivesOutcomeHandles(variant) {
29
+ return variant === undefined || variant === 'quick-form';
30
+ }
31
+ export function hitlExitModel(inputs, pinnedVersion) {
32
+ const exits = (inputs?.outcomes ?? []).map((o, i) => {
33
+ const name = typeof o === 'string' ? o : o?.name;
34
+ return {
35
+ value: String(name ?? ''),
36
+ port: `outcome-${outcomeSlug(name, i)}`,
37
+ endsProcess: typeof o !== 'string' && o?.action === 'End',
38
+ };
39
+ });
21
40
  if (inputs?.exposeError === true || inputs?.outcomePorts === true)
22
- return true;
41
+ return { shape: 'per-outcome', exits };
42
+ if (!hitlDerivesOutcomeHandles(inputs?.variant) || exits.length === 0)
43
+ return { shape: 'placeholder' };
44
+ const every = (why) => ({ shape: 'every-outcome', exits, why });
23
45
  if (inputs?.outcomePorts === false)
24
- return false;
25
- if (inputs?.variant !== undefined)
26
- return false;
46
+ return every('outcomePorts: false');
47
+ if (inputs?.variant === 'quick-form')
48
+ return every('quick-form');
27
49
  if (pinnedVersion !== undefined)
28
- return false;
29
- const outcomes = inputs?.outcomes ?? [];
30
- if (outcomes.length <= 1)
31
- return false;
32
- return !outcomes.every((o) => typeof o !== 'string' && o?.action === 'End');
50
+ return every('pinned');
51
+ if (exits.length === 1)
52
+ return every('one outcome');
53
+ if (exits.every((e) => e.endsProcess))
54
+ return every('every outcome ends');
55
+ return { shape: 'per-outcome', exits };
56
+ }
57
+ /** Does this task route PER OUTCOME (primary continues, the rest are routed)? */
58
+ export function hitlRoutesPerOutcome(inputs, pinnedVersion) {
59
+ return hitlExitModel(inputs, pinnedVersion).shape === 'per-outcome';
60
+ }
61
+ function outcomeIdsOf(node) {
62
+ let schema = node.inputs?.schema;
63
+ if (typeof schema === 'string') {
64
+ try {
65
+ schema = JSON.parse(schema);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ }
71
+ const outcomes = Array.isArray(schema?.outcomes) ? schema.outcomes : [];
72
+ return outcomes.map((o) => o?.id).filter((id) => typeof id === 'string' && id !== '');
73
+ }
74
+ /**
75
+ * Why an edge on `port` cannot leave `node`, or `undefined` when it can.
76
+ *
77
+ * Only the two node types whose handles the product derives from the outcomes
78
+ * are judged, and only once they HAVE outcomes: then `completed` is a handle
79
+ * the product does not draw, and an `outcome-<id>` naming no declared outcome is
80
+ * one `flow validate` refuses ("Edge references undeclared source handle"). A
81
+ * real outcome named "Completed" is `outcome-completed`, which is fine — the
82
+ * bare `completed` never is.
83
+ */
84
+ export function hitlExitViolation(node, port) {
85
+ if (!OUTCOME_HANDLE_NODE_TYPES.has(String(node.type)))
86
+ return undefined;
87
+ const ids = outcomeIdsOf(node);
88
+ if (ids.length === 0)
89
+ return undefined;
90
+ const declared = ids.map((i) => `outcome-${i}`).join(', ');
91
+ if (port === 'completed') {
92
+ return `human task "${String(node.id)}" has outcomes (${declared}), so the product draws no `
93
+ + `'completed' handle on it — an edge there is remapped to the primary outcome and every other `
94
+ + `outcome loses its path.`;
95
+ }
96
+ if (port.startsWith('outcome-') && !ids.includes(port.slice('outcome-'.length))) {
97
+ return `human task "${String(node.id)}" has no outcome for port "${port}" — it declares ${declared}.`;
98
+ }
99
+ return undefined;
100
+ }
101
+ /**
102
+ * The post-emit half of the guard: every edge of an emitted scope, whatever
103
+ * path produced it (a tail, a `.stepToList` seed, a parked `.stepToRef`), is held
104
+ * to {@link hitlExitViolation}. Fails closed — a serializer that emitted one of
105
+ * these would ship a flow that validates and then drops outcomes at run time.
106
+ */
107
+ export function assertHitlExitInvariant(nodes, edges) {
108
+ const byId = new Map(nodes.map((n) => [String(n.id), n]));
109
+ for (const e of edges) {
110
+ const node = byId.get(String(e.sourceNodeId));
111
+ if (!node)
112
+ continue;
113
+ const why = hitlExitViolation(node, String(e.sourcePort));
114
+ if (why)
115
+ throw new Error(`serialize: ${why}`);
116
+ }
33
117
  }
@@ -1,3 +1,4 @@
1
+ import type { Step } from '../flow-sdk.js';
1
2
  /** One author-named exit: the value an arm matches on, and the port it wires. */
2
3
  export interface DeclaredExit {
3
4
  /** What `.stepSwitch()`'s arm `value` matches — the outcome or branch NAME. */
@@ -34,4 +35,11 @@ type SpecLike = {
34
35
  export declare function declaredExits(spec: SpecLike | undefined, pinnedVersion?: string): DeclaredExit[] | NoExits;
35
36
  /** Narrowing helper — `declaredExits` returns one or the other. */
36
37
  export declare function hasExits(r: DeclaredExit[] | NoExits): r is DeclaredExit[];
38
+ /**
39
+ * Whether a step list that hands on no tail got there by reaching an End — a
40
+ * `.return()`, on some path — rather than by stopping the whole run
41
+ * (`.terminate`) or leaving a loop (`.break`). Only the first leaves a sibling
42
+ * `.parallel` join waiting: a terminate ends the run, join included.
43
+ */
44
+ export declare function stopsAtEnd(steps: readonly Step[]): boolean;
37
45
  export {};
@@ -20,7 +20,7 @@
20
20
  * handling and outcome routing in one list, where an omitted arm would mean two
21
21
  * different things.
22
22
  */
23
- import { hitlRoutesPerOutcome, outcomeSlug } from './hitl-routing.js';
23
+ import { hitlExitModel } from './hitl-routing.js';
24
24
  /**
25
25
  * The author-declared exits of `spec`, or a `NoExits` explaining why there are
26
26
  * none to route.
@@ -29,32 +29,29 @@ export function declaredExits(spec, pinnedVersion) {
29
29
  const kind = spec && 'kind' in spec ? spec.kind : undefined;
30
30
  if (kind === 'hitl') {
31
31
  const inputs = spec?.inputs;
32
- const outcomes = (inputs?.outcomes ?? []);
33
- if (outcomes.length === 0) {
34
- return { reason: 'it declares no outcomes', suggestion: `outcomes: ['Approve', 'Reject']` };
35
- }
36
- if (!hitlRoutesPerOutcome(inputs, pinnedVersion)) {
37
- // WHY it kept the single exit decides what the author can do about it, so
32
+ const model = hitlExitModel(inputs, pinnedVersion);
33
+ if (model.shape === 'placeholder') {
34
+ // WHY it has no outcome exits decides what the author can do about it, so
38
35
  // the caller gets the specific one rather than "ports are off".
39
- if (inputs?.outcomePorts === false) {
40
- return {
41
- reason: 'outcomePorts: false keeps its single `completed` exit',
42
- suggestion: 'Drop `outcomePorts: false`.',
43
- };
36
+ if ((inputs?.outcomes ?? []).length === 0) {
37
+ return { reason: 'it declares no outcomes', suggestion: `outcomes: ['Approve', 'Reject']` };
44
38
  }
45
- if (inputs?.variant !== undefined) {
46
- return {
47
- reason: `the '${inputs.variant}' variant has no per-outcome definition version to select`,
48
- suggestion: `Drop the variant, or use .step() and switch on out('<step>', 'Action').`,
49
- };
50
- }
51
- if (pinnedVersion !== undefined) {
39
+ return {
40
+ reason: `the '${inputs?.variant}' variant reads its outcomes from its app and leaves on its single 'completed' exit`,
41
+ suggestion: `Use .step() and switch on out('<step>', 'Action').`,
42
+ };
43
+ }
44
+ // A quick-form task and a pinned one continue every outcome under `.step()`,
45
+ // but their outcome handles are real, so `.stepSwitch` routes them. The
46
+ // other fan-out reasons leave nothing to route.
47
+ if (model.shape === 'every-outcome' && model.why !== 'quick-form' && model.why !== 'pinned') {
48
+ if (model.why === 'outcomePorts: false') {
52
49
  return {
53
- reason: `{ version: '${pinnedVersion}' } pins the definition whose only exit is 'completed'`,
54
- suggestion: 'Drop the pin.',
50
+ reason: 'outcomePorts: false continues every outcome to the next step',
51
+ suggestion: 'Drop `outcomePorts: false`.',
55
52
  };
56
53
  }
57
- if (outcomes.length === 1) {
54
+ if (model.why === 'one outcome') {
58
55
  return {
59
56
  reason: 'a single outcome has a single exit, so there is nothing to route',
60
57
  suggestion: 'Use .step() — the next step follows it unconditionally.',
@@ -65,14 +62,11 @@ export function declaredExits(spec, pinnedVersion) {
65
62
  suggestion: 'Use .step().',
66
63
  };
67
64
  }
68
- return outcomes.map((o, i) => {
69
- const name = (typeof o === 'string' ? o : o?.name) ?? '';
70
- return {
71
- value: String(name),
72
- port: `outcome-${outcomeSlug(name, i)}`,
73
- ...(typeof o !== 'string' && o?.action === 'End' ? { endsProcess: true } : {}),
74
- };
75
- });
65
+ return model.exits.map((e) => ({
66
+ value: e.value,
67
+ port: e.port,
68
+ ...(e.endsProcess ? { endsProcess: true } : {}),
69
+ }));
76
70
  }
77
71
  if (kind === 'http') {
78
72
  const branches = (spec?.inputs?.branches
@@ -100,3 +94,16 @@ export function declaredExits(spec, pinnedVersion) {
100
94
  export function hasExits(r) {
101
95
  return Array.isArray(r);
102
96
  }
97
+ /**
98
+ * Whether a step list that hands on no tail got there by reaching an End — a
99
+ * `.return()`, on some path — rather than by stopping the whole run
100
+ * (`.terminate`) or leaving a loop (`.break`). Only the first leaves a sibling
101
+ * `.parallel` join waiting: a terminate ends the run, join included.
102
+ */
103
+ export function stopsAtEnd(steps) {
104
+ const has = (list, kinds) => list.some((st) => kinds.has(st.kind)
105
+ || (st.kind === 'branch' && (has(st.then, kinds) || has(st.otherwise, kinds)))
106
+ || ((st.kind === 'switch' || st.kind === 'stepSwitch') && st.cases.some((c) => has(c.body, kinds)))
107
+ || (st.kind === 'switch' && st.default !== undefined && has(st.default, kinds)));
108
+ return has(steps, new Set(['return'])) && !has(steps, new Set(['terminate', 'break']));
109
+ }