@uipath/maestro-builder-sdk 5.4.1 → 6.0.1

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/dist/check.js CHANGED
@@ -11,6 +11,8 @@ import { isLookupToken, resolvedValue, unresolvedLookupMessage, } from './core/l
11
11
  import { EVENT_FILTER_OPERATORS, readEventFilter, eventFilterProblem } from './event-filters.js';
12
12
  import { extractRefs } from './core/expr-check.js';
13
13
  import { bindingSelfNameMessage } from './core/binding-messages.js';
14
+ import { hitlRoutesPerOutcome } from './core/hitl-routing.js';
15
+ import { declaredExits, hasExits } from './core/step-ports.js';
14
16
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
15
17
  import { compareSchemaVersions, schemaVersionRefusal } from './schema-version.js';
16
18
  import { prepareCommand } from './core/cli-spelling.js';
@@ -105,11 +107,14 @@ function checkFlow(built, ancestors, opts = {}) {
105
107
  const hitlOutcomeSteps = new Map();
106
108
  const hitlOutcomeRouted = new Map();
107
109
  // The other half of that bookkeeping: hitl steps that declare a CHOICE (more
108
- // than one outcome) and did NOT opt into per-outcome exits, so the graph gives
109
- // every outcome the same single exit. Whether that is a mistake depends on
110
- // something no single step can seewhether anything downstream reads the
111
- // chosen outcome so the decision waits until the expressions are collected
112
- // (see HITL_OUTCOMES_UNREACHABLE below).
110
+ // than one outcome) and still leave on ONE exit, so every outcome leaves the
111
+ // node the same way. Since more than one outcome infers per-outcome routing,
112
+ // reaching this map means the single exit was chosenby a variant, a pinned
113
+ // version, or `outcomePorts: false` and WHICH of the three decides what the
114
+ // remedy can be. Whether it is a mistake at all depends on something no single
115
+ // step can see — whether anything downstream reads the chosen outcome — so the
116
+ // decision waits until the expressions are collected (see
117
+ // HITL_OUTCOMES_UNREACHABLE below).
113
118
  const hitlChoiceNoPorts = new Map();
114
119
  const dataFabricUpdates = [];
115
120
  // Placeholder (`mock()`) step names. The platform's `core.logic.mock` declares
@@ -174,7 +179,16 @@ function checkFlow(built, ancestors, opts = {}) {
174
179
  let prevPortSource;
175
180
  /** Collect an expression, stamped with the error handler it sits in (if any). */
176
181
  const addExpr = (js, step) => { exprs.push({ js, step, errorSource }); };
177
- for (const s of steps) {
182
+ for (const s0 of steps) {
183
+ // A `.stepSwitch` IS an action plus its arms, so every action rule below —
184
+ // name registration, the per-family `check*`, expression collection, the
185
+ // read maps — has to see it as one. Normalising here rather than
186
+ // duplicating forty lines is what keeps a new family rule from silently
187
+ // applying to `.step()` and not to `.stepSwitch()`. The arms and the
188
+ // stepSwitch-only diagnostics are handled after the loop body.
189
+ const s = s0.kind === 'stepSwitch'
190
+ ? { kind: 'action', name: s0.name, spec: s0.spec, ...(s0.options ? { options: s0.options } : {}) }
191
+ : s0;
178
192
  // `return` carries a synthetic `name` that names no node and is not
179
193
  // referenceable — registering it would report DUP_STEP the second time one
180
194
  // appears. The port-edge kinds carry no name at all.
@@ -201,32 +215,40 @@ function checkFlow(built, ancestors, opts = {}) {
201
215
  }
202
216
  }
203
217
  // The mirror of HITL_OUTCOME_PORTS_OFF below: `completed` on a human task
204
- // that HAS opted into per-outcome exits.
218
+ // that routes per outcome.
219
+ //
220
+ // Per-outcome routing moves the step to the node's 1.1/1.2 definition, and
221
+ // those declare ONE source handle — `outcome-{item.id}`, repeated over
222
+ // `inputs.schema.outcomes`. There is no `completed` handle to leave from,
223
+ // so this edge is refused by the product: `flow validate` answers "Edge
224
+ // references undeclared source handle \"completed\" on node \"<step>\""
225
+ // (measured). Without this rule `check` was silent, `compile` emitted it,
226
+ // and the author found out from validate — or from a consumer that
227
+ // inspects ports.
205
228
  //
206
- // Turning on `outcomePorts` (or `exposeError`, which implies it) moves the
207
- // step to the node's 1.1/1.2 definition, and those declare ONE source
208
- // handle `outcome-{item.id}`, repeated over `inputs.schema.outcomes`.
209
- // There is no `completed` handle to leave from, so this edge is refused by
210
- // the product: `flow validate` answers "Edge references undeclared source
211
- // handle \"completed\" on node \"<step>\"" (measured). Without this rule
212
- // `check` was silent, `compile` emitted it, and the author found out from
213
- // validate — or from a consumer that inspects ports.
229
+ // Since more than one outcome INFERS the routing, this is now also the
230
+ // rule an author meets when they write the old shape on a multi-outcome
231
+ // task, so the message names the two ways back to a `completed` exit.
214
232
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port === 'completed') {
215
233
  const prevSpec = prevAction?.spec;
216
234
  const isHitl = !!prevSpec && 'kind' in prevSpec && prevSpec.kind === 'hitl';
217
- const routed = isHitl
218
- && (prevSpec.inputs?.outcomePorts === true || prevSpec.inputs?.exposeError === true);
235
+ const routed = isHitl && hitlRoutesPerOutcome(prevSpec.inputs, prevAction?.options?.version);
219
236
  if (routed) {
220
237
  const first = prevSpec.inputs.outcomes?.[0];
221
238
  const firstId = outcomeSlug(typeof first === 'string' ? first : first?.name, 0);
239
+ const why = prevSpec.inputs?.exposeError === true ? 'exposeError selects'
240
+ : prevSpec.inputs?.outcomePorts === true ? 'outcomePorts selects'
241
+ : `its ${(prevSpec.inputs?.outcomes ?? []).length} outcomes select`;
222
242
  diags.push({
223
243
  level: 'error', code: 'HITL_COMPLETED_PORT_GONE', step: prevAction?.name,
224
- message: `Port "completed" does not exist on human task "${prevAction?.name}". `
225
- + `${prevSpec.inputs?.exposeError === true ? 'exposeError' : 'outcomePorts'} selects the node's `
244
+ message: `Port "completed" does not exist on human task "${prevAction?.name}". ${why} the node's `
226
245
  + `${prevSpec.inputs?.exposeError === true ? '1.2' : '1.1'} definition, whose only source handle is `
227
246
  + `"outcome-<id>" repeated over the outcomes — the 1.0 definition's single 'completed' exit is not `
228
247
  + `part of it. The FIRST outcome ("outcome-${firstId}") is what continues the main path.`,
229
- suggestion: `route the outcomes (.stepToList('outcome-${firstId}', …)), or drop outcomePorts to keep the 'completed' exit`,
248
+ suggestion: prevSpec.inputs?.outcomePorts === true || prevSpec.inputs?.exposeError === true
249
+ ? `route the outcomes (.stepToList('outcome-${firstId}', …)), or drop outcomePorts to keep the 'completed' exit`
250
+ : `route the outcomes (.stepToList('outcome-${firstId}', …)), or keep the single exit with `
251
+ + `outcomePorts: false`,
230
252
  });
231
253
  }
232
254
  }
@@ -241,13 +263,24 @@ function checkFlow(built, ancestors, opts = {}) {
241
263
  + `${prevAction ? `"${prevAction.name}" ` : ''}is not a human task.`,
242
264
  });
243
265
  }
244
- else if (prevSpec.inputs?.outcomePorts !== true && prevSpec.inputs?.exposeError !== true) {
266
+ else if (!hitlRoutesPerOutcome(prevSpec.inputs, prevAction?.options?.version)) {
267
+ // Reachable three ways now that more than one outcome infers the
268
+ // routing: `outcomePorts: false`, a pinned `{ version: '1.0' }`, or a
269
+ // variant that has no per-outcome definition to select.
270
+ const off = prevSpec.inputs?.outcomePorts === false ? 'outcomePorts: false turns them off'
271
+ : prevSpec.inputs?.variant !== undefined
272
+ ? `the '${prevSpec.inputs.variant}' variant has no per-outcome definition version`
273
+ : prevAction?.options?.version !== undefined
274
+ ? `{ version: '${prevAction.options.version}' } pins the definition that has none`
275
+ : 'it declares fewer than two outcomes';
245
276
  diags.push({
246
277
  level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
247
278
  message: `Port "${s.port}" routes a per-outcome exit, but human task "${prevAction?.name}" `
248
- + `has not opted into them — its single exit is 'completed'. Per-outcome exits are the `
279
+ + `does not have them — ${off}, so its single exit is 'completed'. Per-outcome exits are the `
249
280
  + `node's 1.1 definition.`,
250
- suggestion: `outcomePorts: true`,
281
+ suggestion: prevSpec.inputs?.variant !== undefined
282
+ ? `route on out('${prevAction?.name}', 'Action') downstream`
283
+ : `outcomePorts: true`,
251
284
  });
252
285
  }
253
286
  else {
@@ -322,9 +355,15 @@ function checkFlow(built, ancestors, opts = {}) {
322
355
  }
323
356
  if (s.spec.kind === 'mock')
324
357
  mockNames.add(s.name);
325
- if (s.spec.kind === 'hitl'
326
- && (s.spec.inputs?.outcomePorts === true || s.spec.inputs?.exposeError === true)) {
327
- hitlOutcomeSteps.set(s.name, (s.spec.inputs.outcomes ?? []).map((o, i) => outcomeSlug(typeof o === 'string' ? o : o?.name, i)));
358
+ // `.stepSwitch` routes its exits as ARMS, so the `.stepToList` bookkeeping
359
+ // would report every one of them unrouted. STEP_SWITCH_EXIT_UNROUTED is
360
+ // that step's version of the same question.
361
+ if (s0.kind !== 'stepSwitch'
362
+ && s.spec.kind === 'hitl' && hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
363
+ hitlOutcomeSteps.set(s.name, (s.spec.inputs.outcomes ?? []).map((o, i) => ({
364
+ id: outcomeSlug(typeof o === 'string' ? o : o?.name, i),
365
+ endsProcess: typeof o !== 'string' && o?.action === 'End',
366
+ })));
328
367
  }
329
368
  if (s.spec.kind === 'hitl') {
330
369
  checkHitl(s.name, s.spec.inputs, diags);
@@ -336,10 +375,12 @@ function checkFlow(built, ancestors, opts = {}) {
336
375
  const allEnd = outcomes.length > 0
337
376
  && outcomes.every((o) => typeof o !== 'string' && o?.action === 'End');
338
377
  if (outcomes.length > 1 && !allEnd
339
- && s.spec.inputs?.outcomePorts !== true && s.spec.inputs?.exposeError !== true) {
378
+ && !hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
340
379
  hitlChoiceNoPorts.set(s.name, {
341
380
  names: outcomes.map((o) => String((typeof o === 'string' ? o : o?.name) ?? '').trim()),
342
381
  variant: s.spec.inputs?.variant,
382
+ pinned: s.options?.version,
383
+ portsOff: s.spec.inputs?.outcomePorts === false,
343
384
  });
344
385
  }
345
386
  }
@@ -579,6 +620,76 @@ function checkFlow(built, ancestors, opts = {}) {
579
620
  checkOutputCategory(name, e, outputType, typeOf, diags, triggerId);
580
621
  }
581
622
  }
623
+ // AFTER the dispatch chain, not inside it: `s` was normalised to an action
624
+ // above, so an `else if` here would be unreachable — which it silently was
625
+ // on the first cut. The action rules have run by now; what is left is the
626
+ // arms and the three things only this step can get wrong.
627
+ if (s0.kind === 'stepSwitch') {
628
+ const exits = declaredExits(s0.spec, s0.options?.version);
629
+ if (!hasExits(exits)) {
630
+ diags.push({
631
+ level: 'error', code: 'STEP_SWITCH_NO_EXITS', step: s0.name,
632
+ message: `.stepSwitch("${s0.name}") routes this step's declared exits, but ${exits.reason}, `
633
+ + `so there are none to route.`,
634
+ ...(exits.suggestion ? { suggestion: exits.suggestion } : {}),
635
+ });
636
+ for (const c of s0.cases)
637
+ walk(c.body, inLoop);
638
+ }
639
+ else {
640
+ const seenExit = new Set();
641
+ for (const c of s0.cases) {
642
+ const exit = exits.find((e) => e.value === c.value);
643
+ if (!exit) {
644
+ const nearest = nearestName(c.value, exits.map((e) => e.value));
645
+ diags.push({
646
+ level: 'error', code: 'STEP_SWITCH_EXIT_UNKNOWN', step: s0.name,
647
+ message: `.stepSwitch("${s0.name}") has an arm for ${JSON.stringify(c.value)}, which is not `
648
+ + `an exit it declares — those are ${exits.map((e) => JSON.stringify(e.value)).join(', ')}.`,
649
+ ...(nearest !== undefined ? { suggestion: `value: '${nearest}'` } : {}),
650
+ });
651
+ }
652
+ else if (seenExit.has(exit.port)) {
653
+ diags.push({
654
+ level: 'error', code: 'STEP_SWITCH_EXIT_DUP', step: s0.name,
655
+ message: `.stepSwitch("${s0.name}") has two arms for ${JSON.stringify(c.value)}. A port takes `
656
+ + `exactly one outgoing edge, so put everything that exit needs in one arm.`,
657
+ });
658
+ }
659
+ else {
660
+ seenExit.add(exit.port);
661
+ }
662
+ if (c.body.length === 0) {
663
+ diags.push({
664
+ level: 'warning', code: 'EMPTY_ARM', step: s0.name,
665
+ message: `.stepSwitch("${s0.name}") has an empty arm for ${JSON.stringify(c.value)}.`,
666
+ });
667
+ }
668
+ walk(c.body, inLoop);
669
+ }
670
+ // An exit with no arm compiles to an End node, so the run FINISHES
671
+ // there instead of reaching a port with no edge and stalling. Better
672
+ // than stalling, still probably not what the author meant — and the
673
+ // End carries none of the flow's output mappings, so the run answers
674
+ // with nothing (flow-builder-sdk#742). An `action: 'End'` outcome
675
+ // already ends the run and needs no arm.
676
+ const unrouted = exits.filter((e) => !seenExit.has(e.port) && e.endsProcess !== true);
677
+ if (unrouted.length > 0) {
678
+ const outs = built.outputs.map((o) => o.name);
679
+ diags.push({
680
+ level: 'warning', code: 'STEP_SWITCH_EXIT_UNROUTED', step: s0.name,
681
+ message: `.stepSwitch("${s0.name}") has no arm for `
682
+ + `${unrouted.map((e) => JSON.stringify(e.value)).join(', ')}. Each compiles to an End node, `
683
+ + `so the run finishes there`
684
+ + (outs.length > 0
685
+ ? ` without writing the flow's declared output${outs.length === 1 ? '' : 's'} `
686
+ + `(${outs.join(', ')}).`
687
+ : '.'),
688
+ suggestion: `{ value: '${unrouted[0].value}', body: (b) => … }`,
689
+ });
690
+ }
691
+ }
692
+ }
582
693
  }
583
694
  };
584
695
  walk(built.steps);
@@ -586,14 +697,34 @@ function checkFlow(built, ancestors, opts = {}) {
586
697
  // The FIRST (primary) outcome continues the main path — only the rest need
587
698
  // an explicit .stepToList route. An unrouted one deploys, then stalls the
588
699
  // run when the reviewer picks it (the designer flags the same, as a warning).
700
+ //
701
+ // `action: 'End'` does NOT excuse a route here. It changes the CONSEQUENCE —
702
+ // the run ends rather than stalling — but the designer draws a handle for
703
+ // every entry in `inputs.schema.outcomes` whatever its action, so an
704
+ // unrouted one is a handle wired to nothing either way. UiPath/skills'
705
+ // `assert_outcome_wiring` requires an edge per outcome with no exemption,
706
+ // and its convention makes every non-primary outcome an `End`, so exempting
707
+ // them here produced artifacts that gate refuses. (`.stepSwitch` has no such
708
+ // gap: an omitted exit compiles to an End node, End action or not.)
589
709
  const routed = hitlOutcomeRouted.get(step) ?? new Set();
590
- const unrouted = ids.slice(1).filter((id) => !routed.has(id));
710
+ const unrouted = ids.slice(1).filter((o) => !routed.has(o.id));
591
711
  if (unrouted.length > 0) {
712
+ const names = unrouted.map((o) => `outcome-${o.id}`).join(', ');
713
+ const stalls = unrouted.filter((o) => !o.endsProcess);
714
+ const ends = unrouted.filter((o) => o.endsProcess);
592
715
  diags.push({
593
716
  level: 'warning', code: 'HITL_OUTCOME_UNROUTED', step,
594
- message: `Human task "${step}" routes per outcome, but ${unrouted.map((i) => `outcome-${i}`).join(', ')} `
595
- + `ha${unrouted.length === 1 ? 's' : 've'} no route. The run stalls if the reviewer picks one.`,
596
- suggestion: `.stepToList('outcome-${unrouted[0]}', (b) => …)`,
717
+ message: `Human task "${step}" routes per outcome, but ${names} `
718
+ + `ha${unrouted.length === 1 ? 's' : 've'} no route.`
719
+ + (stalls.length > 0
720
+ ? ` The run stalls if the reviewer picks ${stalls.length === 1 ? 'it' : 'one of those'}.`
721
+ : '')
722
+ + (ends.length > 0
723
+ ? ` ${ends.map((o) => `outcome-${o.id}`).join(', ')} end${ends.length === 1 ? 's' : ''} the run `
724
+ + `(action: 'End'), so nothing stalls — but the handle the canvas draws is still wired to `
725
+ + `nothing. Route it to a .return() so the graph says where that outcome finishes.`
726
+ : ''),
727
+ suggestion: `.stepToList('outcome-${unrouted[0].id}', (b) => b.return({…}))`,
597
728
  });
598
729
  }
599
730
  }
@@ -3846,24 +3977,30 @@ function outcomeSlug(name, index) {
3846
3977
  * HITL_OUTCOMES_UNREACHABLE — a human task offers the reviewer a CHOICE that
3847
3978
  * nothing downstream can act on.
3848
3979
  *
3849
- * `outcomes` and `outcomePorts` are two halves of one decision, and only the
3850
- * first half reads as load-bearing. Declaring outcomes without the flag emits
3851
- * the node's 1.0 definition, whose ONE source handle is `completed`: the
3852
- * outcomes still reach the reviewer and still appear in
3853
- * `inputs.schema.outcomes`, and then every button the reviewer can press leaves
3854
- * the node the same way. Two outcomes, one path an approval whose approve and
3855
- * reject arms are indistinguishable is a modelling error, not a style choice,
3856
- * yet it compiles and `flow validate` answers `Valid` (flow-builder-sdk#735).
3980
+ * The single-exit shape publishes the outcomes and routes none of them: the 1.0
3981
+ * definition's ONE source handle is `completed`, so the outcomes reach the
3982
+ * reviewer and `inputs.schema.outcomes`, and then every button the reviewer can
3983
+ * press leaves the node the same way. Two outcomes, one path — an approval whose
3984
+ * approve and reject arms are indistinguishable is a modelling error, not a
3985
+ * style choice, yet it compiles and `flow validate` answers `Valid`
3986
+ * (flow-builder-sdk#735).
3987
+ *
3988
+ * On a plain base task that is no longer reachable: more than one outcome now
3989
+ * INFERS per-outcome routing (`hitlRoutesPerOutcome`, #739), because a warning
3990
+ * alone did not change what agents emitted — #739 measured the diagnostic
3991
+ * firing three times and the unreachable artifact shipping anyway. What is left
3992
+ * for this rule is the cases where the single exit is CHOSEN and the choice may
3993
+ * still be a mistake: a `variant` (no per-outcome definition version exists to
3994
+ * select, #729), a pinned `{ version: '1.0' }`, and an explicit
3995
+ * `outcomePorts: false`.
3857
3996
  *
3858
- * The one thing that makes the single-exit shape correct is a read of the
3859
- * chosen outcome: `out('<step>', 'Action')` feeding a `.switch()` is the
3860
- * DEFAULT documented shape, and it is the only shape the quick-form,
3861
- * action-app and document-validation variants have they carry no per-outcome
3862
- * definition for `outcomePorts` to select (HITL_OUTCOME_PORTS_VARIANT). So the
3863
- * rule is not "outcomes without ports", which would refuse that shape; it is
3864
- * "outcomes that no exit and no expression distinguishes". That is why it runs
3865
- * here, after `walk`, rather than inside `checkHitl`: the deciding evidence is
3866
- * the rest of the flow.
3997
+ * In all three the shape is correct when something reads the chosen outcome:
3998
+ * `out('<step>', 'Action')` feeding a `.switch()` routes the decision as data,
3999
+ * and it is the ONLY shape the quick-form, action-app and document-validation
4000
+ * variants have. So the rule is not "outcomes without ports", which would
4001
+ * refuse that shape; it is "outcomes that no exit and no expression
4002
+ * distinguishes". That is why it runs here, after `walk`, rather than inside
4003
+ * `checkHitl`: the deciding evidence is the rest of the flow.
3867
4004
  *
3868
4005
  * A read counts when it can SEE the outcome — `.output.Action`, the whole
3869
4006
  * answer object (`out('<step>')` / `$vars.<step>.output`, which carries
@@ -3893,23 +4030,34 @@ function reportUnreachableHitlOutcomes(choices, exprs, diags) {
3893
4030
  for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)\s*\[/g))
3894
4031
  readsOutcome.add(m[1]);
3895
4032
  }
3896
- for (const [step, { names, variant }] of choices) {
4033
+ for (const [step, { names, variant, pinned, portsOff }] of choices) {
3897
4034
  if (readsOutcome.has(step))
3898
4035
  continue;
3899
4036
  const ids = names.map((n, i) => outcomeSlug(n, i));
4037
+ // Why this task kept the single exit decides which remedy is even legal:
4038
+ // `outcomePorts: true` is refused on a variant and conflicts with a pin, so
4039
+ // offering it there would trade a warning for a compile error.
4040
+ // On a base task with no pin, more than one outcome would have inferred the
4041
+ // routing — so the only way such a task reaches here is `outcomePorts:
4042
+ // false`, and it is the only case where offering the flag is legal.
4043
+ const canTakePorts = variant === undefined && pinned === undefined && portsOff === true;
4044
+ const kept = variant !== undefined
4045
+ ? `The "${variant}" variant has no per-outcome definition version to select`
4046
+ : pinned !== undefined
4047
+ ? `{ version: '${pinned}' } pins the definition whose only exit is 'completed'`
4048
+ : `outcomePorts: false keeps the single exit`;
3900
4049
  diags.push({
3901
4050
  level: 'warning', code: 'HITL_OUTCOMES_UNREACHABLE', step,
3902
4051
  message: `Human task "${step}" declares ${names.length} outcomes (${names.join(', ')}), but nothing `
3903
4052
  + `downstream tells them apart: the node emits its single 'completed' exit and every outcome leaves `
3904
4053
  + `on it, and no expression reads out('${step}', 'Action') either. The outcomes reach the reviewer `
3905
- + `and the artifact; the decision does not reach the flow. `
3906
- + (variant === undefined
3907
- ? `Set outcomePorts: true to give each outcome its own exit (${ids.map((i) => `'outcome-${i}'`).join(', ')}), `
3908
- + `or switch on out('${step}', 'Action') to route it as data.`
3909
- : `The "${variant}" variant has no per-outcome definition for outcomePorts to select, so switch on `
3910
- + `out('${step}', 'Action') to route it as data.`),
3911
- suggestion: variant === undefined
3912
- ? 'outcomePorts: true'
4054
+ + `and the artifact; the decision does not reach the flow. ${kept}, so switch on `
4055
+ + `out('${step}', 'Action') to route the decision as data`
4056
+ + (canTakePorts
4057
+ ? `, or drop outcomePorts: false for per-outcome exits (${ids.map((i) => `'outcome-${i}'`).join(', ')}).`
4058
+ : '.'),
4059
+ suggestion: canTakePorts
4060
+ ? 'drop outcomePorts: false'
3913
4061
  : `.switch('route', out('${step}', 'Action'), …)`,
3914
4062
  });
3915
4063
  }
@@ -564,14 +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. Declaring them creates no EXITS: with
568
- * more than one, set `outcomePorts` to fork in the graph, or read
569
- * `out('<step>', 'Action')` to route as data. With neither, every outcome
570
- * leaves on the same `completed` exit and nothing downstream can tell them
571
- * apart which `check` warns about as `HITL_OUTCOMES_UNREACHABLE`.
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.
572
573
  *
573
- * Which one they pressed is `out('<step>', 'Action')` — that is what the
574
- * platform's own routing conditions compare against, so branch on it.
574
+ * 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.
575
577
  */
576
578
  outcomes: Outcome[];
577
579
  /** Who gets the task and how. Omit for the definition's default delivery. */
@@ -579,16 +581,18 @@ export interface HitlInputs {
579
581
  /** Action Center labels, comma-separated (the platform stores one string). */
580
582
  labels?: string;
581
583
  /**
582
- * Route each outcome from its OWN port instead of the single `completed`
583
- * exit. Selects the node's **1.1** definition, whose exits are
584
- * `outcome-<id>` handles (one per outcome; ids are the outcome names
585
- * slugified, e.g. `'Approve'` `outcome-approve`). It REPLACES `completed`
586
- * rather than adding to it, and it is not an optional refinement — see
587
- * `outcomes` for the choice it is one half of.
588
- *
589
- * The FIRST (primary) outcome continues the main path; route the others with
590
- * `.stepToList('outcome-<id>', …)`. Base variant only the sub-typed nodes
591
- * have no per-outcome definition version.
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')`.
588
+ *
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.
592
596
  */
593
597
  outcomePorts?: boolean;
594
598
  /**
@@ -0,0 +1,47 @@
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.
36
+ */
37
+ import type { HitlInputs } from './actions.js';
38
+ /**
39
+ * An outcome's stable id — the slug that names its `outcome-<id>` port.
40
+ *
41
+ * One definition, because `serialize` emits these ids, `check` validates port
42
+ * names against them, and `.stepSwitch` resolves an arm's `value` through them.
43
+ * Three copies of one rule is three chances for a port name to be emitted that
44
+ * nothing else recognises.
45
+ */
46
+ export declare function outcomeSlug(name: unknown, index: number): string;
47
+ export declare function hitlRoutesPerOutcome(inputs: HitlInputs | undefined, pinnedVersion?: string): boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * An outcome's stable id — the slug that names its `outcome-<id>` port.
3
+ *
4
+ * One definition, because `serialize` emits these ids, `check` validates port
5
+ * names against them, and `.stepSwitch` resolves an arm's `value` through them.
6
+ * Three copies of one rule is three chances for a port name to be emitted that
7
+ * nothing else recognises.
8
+ */
9
+ export function outcomeSlug(name, index) {
10
+ const slug = String(name ?? '')
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, '-')
14
+ .replace(/^-+|-+$/g, '');
15
+ return slug || `outcome-${index + 1}`;
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.
21
+ if (inputs?.exposeError === true || inputs?.outcomePorts === true)
22
+ return true;
23
+ if (inputs?.outcomePorts === false)
24
+ return false;
25
+ if (inputs?.variant !== undefined)
26
+ return false;
27
+ 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');
33
+ }
@@ -0,0 +1,37 @@
1
+ /** One author-named exit: the value an arm matches on, and the port it wires. */
2
+ export interface DeclaredExit {
3
+ /** What `.stepSwitch()`'s arm `value` matches — the outcome or branch NAME. */
4
+ value: string;
5
+ /** The emitted source handle. */
6
+ port: string;
7
+ /**
8
+ * This exit ends the RUN, so it needs no arm and no auto-End: a human-task
9
+ * outcome with `action: 'End'`.
10
+ */
11
+ endsProcess?: boolean;
12
+ /** The fall-through exit — HTTP's `default`. Last, and optional in an arm list. */
13
+ isDefault?: boolean;
14
+ }
15
+ /** Why a spec has no routable fan-out, phrased for a diagnostic. */
16
+ export interface NoExits {
17
+ reason: string;
18
+ /** What to do instead. */
19
+ suggestion?: string;
20
+ }
21
+ /**
22
+ * Structural, not the `FlowActionSpec` union: this module is imported by
23
+ * `flow-sdk.ts`'s dependents AND describes their specs, so naming the union
24
+ * here would close a cycle. Every field it reads is optional anyway.
25
+ */
26
+ type SpecLike = {
27
+ kind?: string;
28
+ inputs?: unknown;
29
+ };
30
+ /**
31
+ * The author-declared exits of `spec`, or a `NoExits` explaining why there are
32
+ * none to route.
33
+ */
34
+ export declare function declaredExits(spec: SpecLike | undefined, pinnedVersion?: string): DeclaredExit[] | NoExits;
35
+ /** Narrowing helper — `declaredExits` returns one or the other. */
36
+ export declare function hasExits(r: DeclaredExit[] | NoExits): r is DeclaredExit[];
37
+ export {};