@uipath/maestro-builder-sdk 5.4.1 → 6.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/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 {};
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The exits an action declares that the AUTHOR named — a human task's outcomes,
3
+ * an HTTP call's response branches — and the port each one leaves on.
4
+ *
5
+ * This is what `.stepSwitch()` routes over, and the reason it exists as a table
6
+ * rather than as two more `if (spec.kind === …)` arms: `serialize` has to turn
7
+ * an arm's `value` into a port, `check` has to say which values are legal and
8
+ * which were left out, and `decompile` has to turn a port back into a value.
9
+ * Those three read the same list here instead of each carrying their own copy
10
+ * of "how a human task names its ports" and "how an HTTP branch names its
11
+ * ports".
12
+ *
13
+ * `undefined` means the family has no author-declared fan-out at all, which is
14
+ * what makes `.stepSwitch()` on it an error rather than a no-op.
15
+ *
16
+ * The `error` port is deliberately NOT here. It is declared by the definition
17
+ * rather than by the author, it exists on families that have no other fan-out,
18
+ * and it is already routed by `.onError()` / `.stepToList('error', …)`. Folding
19
+ * it in would make every action a `.stepSwitch` candidate and would put failure
20
+ * handling and outcome routing in one list, where an omitted arm would mean two
21
+ * different things.
22
+ */
23
+ import { hitlRoutesPerOutcome, outcomeSlug } from './hitl-routing.js';
24
+ /**
25
+ * The author-declared exits of `spec`, or a `NoExits` explaining why there are
26
+ * none to route.
27
+ */
28
+ export function declaredExits(spec, pinnedVersion) {
29
+ const kind = spec && 'kind' in spec ? spec.kind : undefined;
30
+ if (kind === 'hitl') {
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
38
+ // 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
+ };
44
+ }
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) {
52
+ return {
53
+ reason: `{ version: '${pinnedVersion}' } pins the definition whose only exit is 'completed'`,
54
+ suggestion: 'Drop the pin.',
55
+ };
56
+ }
57
+ if (outcomes.length === 1) {
58
+ return {
59
+ reason: 'a single outcome has a single exit, so there is nothing to route',
60
+ suggestion: 'Use .step() — the next step follows it unconditionally.',
61
+ };
62
+ }
63
+ return {
64
+ reason: 'every outcome ends the process, so no exit distinguishes them',
65
+ suggestion: 'Use .step().',
66
+ };
67
+ }
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
+ });
76
+ }
77
+ if (kind === 'http') {
78
+ const branches = (spec?.inputs?.branches
79
+ ?? []);
80
+ if (branches.length === 0) {
81
+ return {
82
+ reason: 'it declares no response branches',
83
+ suggestion: `branches: [{ name: 'rateLimited', condition: … }]`,
84
+ };
85
+ }
86
+ // The fall-through is an exit like any other here — it is what `.switch()`
87
+ // spells as its default arm, and leaving it out is the same omission as
88
+ // leaving out a branch.
89
+ return [
90
+ ...branches.map((b) => ({ value: String(b?.name ?? ''), port: `branch-${b?.name ?? ''}` })),
91
+ { value: 'default', port: 'default', isDefault: true },
92
+ ];
93
+ }
94
+ return {
95
+ reason: `a ${kind ?? 'custom'} step declares no author-named exits`,
96
+ suggestion: 'Use .step(); route failures with .onError().',
97
+ };
98
+ }
99
+ /** Narrowing helper — `declaredExits` returns one or the other. */
100
+ export function hasExits(r) {
101
+ return Array.isArray(r);
102
+ }
package/dist/decompile.js CHANGED
@@ -408,16 +408,39 @@ class Graph {
408
408
  const first = outcomes[0]?.id;
409
409
  return typeof first === 'string' && first !== '' ? `outcome-${first}` : undefined;
410
410
  }
411
+ /**
412
+ * Every declared exit of `id`, when ALL of them are wired — the `.stepSwitch`
413
+ * shape. `undefined` otherwise, which leaves the node with a tacit main path
414
+ * and its siblings as side arms.
415
+ */
416
+ fullyWiredExits(id) {
417
+ const node = this.byId.get(id);
418
+ const declared = node ? declaredExitsOfNode(node) : undefined;
419
+ if (declared === undefined || declared.length < 2)
420
+ return undefined;
421
+ const wired = new Set(this.outEdges(id).map((e) => e.sourcePort));
422
+ if (!declared.every((d) => wired.has(d.port)))
423
+ return undefined;
424
+ return new Set(declared.map((d) => d.port));
425
+ }
411
426
  /**
412
427
  * Out-edges excluding the side paths recovered as port lists: `error`,
413
428
  * `branch-*`, and every `outcome-*` except the primary one.
429
+ *
430
+ * UNLESS every declared exit is wired. Then the node genuinely FORKS — there
431
+ * is no main path, each arm is a successor — and the post-dominator has to see
432
+ * that or it computes the join as the primary arm's first node. Which it did:
433
+ * a converged two-outcome task decompiled with one arm empty and the other
434
+ * swallowing the tail, because `ipdom(review)` came back as `publish`.
414
435
  */
415
436
  successorEdges(id) {
416
437
  const primary = this.primaryOutcomePort(id);
438
+ const forked = this.fullyWiredExits(id);
417
439
  return this.outEdges(id).filter((e) => e.sourcePort !== 'error'
418
440
  && !ARTIFACT_PORTS.has(e.sourcePort)
419
- && !e.sourcePort.startsWith('branch-')
420
- && (!e.sourcePort.startsWith('outcome-') || e.sourcePort === primary));
441
+ && (forked?.has(e.sourcePort)
442
+ || (!e.sourcePort.startsWith('branch-')
443
+ && (!e.sourcePort.startsWith('outcome-') || e.sourcePort === primary))));
421
444
  }
422
445
  }
423
446
  // ─── Node → step-spec source ──────────────────────────────────────────────────
@@ -1952,6 +1975,25 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
1952
1975
  }
1953
1976
  // A regular action step (mock / script / http / transform / connector / …).
1954
1977
  const spec = emitStepSpec(node, ctx.imp, ctx.inputNames, ctx.o, ctx.graph);
1978
+ // `.stepSwitch` when the node's author-declared exits are ALL wired and the
1979
+ // arms rejoin: that graph has no tacit main path to recover, so the
1980
+ // `.step` + `.stepToList` form below cannot express it. It used to come back
1981
+ // as `.stepToRef("end")` — a reference to the synthetic End node, which
1982
+ // `compile` then refused (REF_UNKNOWN_TARGET), so a converged port graph did
1983
+ // not round-trip at all. Reachable from the designer long before
1984
+ // `.stepSwitch` existed; the arms just had nothing to decompile INTO.
1985
+ const exitPorts = portArmExits(node, ctx);
1986
+ if (exitPorts && exitPorts.length > 1) {
1987
+ const r = ipdom.get(node.id) ?? stop;
1988
+ const armSrcs = exitPorts.map(({ value, port }) => {
1989
+ const armSegs = region(portTarget(ctx.graph, node.id, port), r, scope, ipdom, ctx, refInto);
1990
+ return `{ value: ${str(value)}, body: ${armCb('b', armSegs)} }`;
1991
+ });
1992
+ const armList = `[\n${armSrcs.map((a) => reindent(a, ' ')).join(',\n')},\n]`;
1993
+ segs.push(`.stepSwitch(${str(node.id)}, ${spec}, ${armList}${optsArg(node, ctx)})`);
1994
+ cursor = r === EXIT ? undefined : r;
1995
+ continue;
1996
+ }
1955
1997
  segs.push(`.step(${str(node.id)}, ${spec}${optsArg(node, ctx)})`);
1956
1998
  // An error handler on this step (source port `error` + `errorHandlingEnabled`).
1957
1999
  const succ = scopeSucc(ctx.graph, node.id, scope);
@@ -2037,6 +2079,53 @@ function forwardReach(entries, scope, graph) {
2037
2079
  }
2038
2080
  return seen;
2039
2081
  }
2082
+ /**
2083
+ * The node's author-declared exits when EVERY one of them is wired — the shape
2084
+ * `.stepSwitch` authors. `undefined` when the node has no such exits, or when
2085
+ * one is unwired, in which case the `.step` + `.stepToList` recovery still
2086
+ * applies and the unwired port stays unwired.
2087
+ *
2088
+ * Deliberately strict about "every one": a partially wired node still has a
2089
+ * tacit main path, and recovering THAT as `.stepSwitch` would invent arms the
2090
+ * flow does not have.
2091
+ */
2092
+ function portArmExits(node, ctx) {
2093
+ // One predicate, on the Graph, because `successorEdges` has to agree with this
2094
+ // exactly: if the CFG forks here the arms must be emitted as arms, and if it
2095
+ // does not they must not be.
2096
+ if (ctx.graph.fullyWiredExits(node.id) === undefined)
2097
+ return undefined;
2098
+ return declaredExitsOfNode(node);
2099
+ }
2100
+ /**
2101
+ * The exits a node declares, read back off the EMITTED artifact rather than off
2102
+ * an authored spec — `inputs.schema.outcomes` for a human task at 1.1/1.2,
2103
+ * `inputs.branches` plus `default` for an HTTP call. The authoring-side twin is
2104
+ * `declaredExits` in `core/step-ports.ts`; they answer about different inputs,
2105
+ * which is why this is not a call into it.
2106
+ */
2107
+ function declaredExitsOfNode(node) {
2108
+ const inputs = (node.inputs ?? {});
2109
+ if (String(node.type).startsWith('uipath.human-in-the-loop')) {
2110
+ const version = String(node.typeVersion ?? '');
2111
+ if (version !== '1.1' && version !== '1.2')
2112
+ return undefined;
2113
+ const outcomes = (inputs.schema?.outcomes ?? []);
2114
+ if (outcomes.length === 0)
2115
+ return undefined;
2116
+ return outcomes.map((o) => ({ value: String(o?.name ?? ''), port: `outcome-${o?.id ?? ''}` }));
2117
+ }
2118
+ if (node.type === T.http || node.type === T.httpV2) {
2119
+ const branches = (inputs.branches ?? []);
2120
+ if (branches.length === 0)
2121
+ return undefined;
2122
+ return [
2123
+ ...branches.map((b) => ({ value: String(b?.name ?? ''), port: `branch-${b?.name ?? ''}` })),
2124
+ { value: 'default', port: 'default' },
2125
+ ];
2126
+ }
2127
+ return undefined;
2128
+ }
2040
2129
  /**
2041
2130
  * Emit a `.switch(...)` from a `core.logic.switch` node. Each case's serialized
2042
2131
  * expression is `<discriminant> === <JSON-literal>`; the discriminant (shared by
@@ -85,6 +85,10 @@ function addStepNames(steps, roots) {
85
85
  if (s.default)
86
86
  addStepNames(s.default, roots);
87
87
  break;
88
+ case 'stepSwitch':
89
+ for (const c of s.cases)
90
+ addStepNames(c.body, roots);
91
+ break;
88
92
  case 'loop':
89
93
  addStepNames(s.body, roots);
90
94
  break;
@@ -145,6 +149,13 @@ function walk(steps, roots, out) {
145
149
  walk(s.default, caseRoots, out);
146
150
  break;
147
151
  }
152
+ case 'stepSwitch':
153
+ // The action's own inputs, then each arm — no discriminant to bind, the
154
+ // exits are named by the step rather than compared against a value.
155
+ deepExprs(s.spec.inputs, s.name, roots, out);
156
+ for (const c of s.cases)
157
+ walk(c.body, roots, out);
158
+ break;
148
159
  case 'loop':
149
160
  pushExpr(s.collection.js, s.collection.literal, s.name, roots, out);
150
161
  if (s.options?.completionCondition) {
@@ -292,6 +292,19 @@ export interface SwitchArm {
292
292
  label?: string;
293
293
  body: Step[];
294
294
  }
295
+ /**
296
+ * One arm of a built `.stepSwitch` — the same shape as {@link SwitchArm}, but
297
+ * `value` names an exit the ACTION declares (a human-task outcome, an HTTP
298
+ * response branch) rather than a value to compare against. No `label`: a port
299
+ * edge carries none, and the exit's own name is already the label the canvas
300
+ * draws.
301
+ */
302
+ export interface PortArm {
303
+ /** The exit this arm routes — a human-task outcome name, an HTTP branch name, or `'default'`. */
304
+ value: string;
305
+ /** The steps that run on that exit. Ends in `.return()` to be terminal; otherwise it converges. */
306
+ body: Step[];
307
+ }
295
308
  /**
296
309
  * Options shared by every builder method that creates a definition-backed node:
297
310
  * `version` selects the exact node definition to compile against, and `updates`
@@ -425,6 +438,12 @@ export type Step = {
425
438
  cases: SwitchArm[];
426
439
  default?: Step[];
427
440
  options?: NodeOptions;
441
+ } | {
442
+ kind: 'stepSwitch';
443
+ name: string;
444
+ spec: FlowActionSpec;
445
+ cases: PortArm[];
446
+ options?: NodeOptions;
428
447
  } | {
429
448
  kind: 'loop';
430
449
  name: string;
@@ -693,6 +712,48 @@ declare class StepList {
693
712
  * @returns This builder, so calls chain.
694
713
  */
695
714
  step(name: string, spec: FlowActionSpec | FlowAction, options?: NodeOptions): this;
715
+ /**
716
+ * Add an action node and route EVERY exit it declares, one arm per exit.
717
+ *
718
+ * The symmetric form of `.step()` + `.stepToList()`. Where those make the
719
+ * first exit the tacit next step and the rest side branches, this makes all
720
+ * of them arms of one construct — so nothing about a human task's routing
721
+ * depends on which outcome happens to be listed first.
722
+ *
723
+ * Arms behave exactly like `.switch()`'s: one that ends in `.return()` is
724
+ * terminal, and one that does not CONVERGES, so the step after the
725
+ * `.stepSwitch` fans in from every arm that reaches it. (That is the
726
+ * difference from `.stepToList`, whose arms get an End of their own.)
727
+ *
728
+ * `value` names an exit the ACTION declares — a human task's outcome name, an
729
+ * HTTP response branch's name, or `'default'` for HTTP's fall-through — not a
730
+ * value to compare against. `check` refuses a value the step does not declare
731
+ * and warns about a declared exit left out, which compiles to an End node.
732
+ *
733
+ * @param name - The step's id, as `.step()`.
734
+ * @param spec - What the node does, from an action factory.
735
+ * @param cases - One arm per declared exit.
736
+ * @param options - Node options, as `.step()`.
737
+ * @returns This builder, so calls chain.
738
+ *
739
+ * @example
740
+ * **Route a human task's outcomes, and converge on one return**
741
+ * ```ts
742
+ * export default flow('draft-review')
743
+ * .output({ summary: types.string })
744
+ * .var('summary', types.string)
745
+ * .stepSwitch('review', hitl({ fields: [], outcomes: ['Publish', 'Revise'] }), [
746
+ * { value: 'Publish', body: (b) => b.step('publish', script({ code: 'return "published";', returns: 'string' }), { updates: { summary: out('publish') } }) },
747
+ * { value: 'Revise', body: (b) => b.step('sendBack', script({ code: 'return "revise";', returns: 'string' }), { updates: { summary: out('sendBack') } }) },
748
+ * ])
749
+ * .return({ summary: v('summary') })
750
+ * .build();
751
+ * ```
752
+ */
753
+ stepSwitch(name: string, spec: FlowActionSpec | FlowAction, cases: {
754
+ value: string;
755
+ body: (b: ArmBuilder) => void;
756
+ }[], options?: NodeOptions): this;
696
757
  /**
697
758
  * Handle the PREVIOUS step's failure: if it fails, the flow runs `bodyFn`'s
698
759
  * steps instead of continuing.
package/dist/flow-sdk.js CHANGED
@@ -303,6 +303,67 @@ class StepList {
303
303
  this.steps.push({ kind: 'action', name, spec: spec, ...(options ? { options } : {}) });
304
304
  return this;
305
305
  }
306
+ /**
307
+ * Add an action node and route EVERY exit it declares, one arm per exit.
308
+ *
309
+ * The symmetric form of `.step()` + `.stepToList()`. Where those make the
310
+ * first exit the tacit next step and the rest side branches, this makes all
311
+ * of them arms of one construct — so nothing about a human task's routing
312
+ * depends on which outcome happens to be listed first.
313
+ *
314
+ * Arms behave exactly like `.switch()`'s: one that ends in `.return()` is
315
+ * terminal, and one that does not CONVERGES, so the step after the
316
+ * `.stepSwitch` fans in from every arm that reaches it. (That is the
317
+ * difference from `.stepToList`, whose arms get an End of their own.)
318
+ *
319
+ * `value` names an exit the ACTION declares — a human task's outcome name, an
320
+ * HTTP response branch's name, or `'default'` for HTTP's fall-through — not a
321
+ * value to compare against. `check` refuses a value the step does not declare
322
+ * and warns about a declared exit left out, which compiles to an End node.
323
+ *
324
+ * @param name - The step's id, as `.step()`.
325
+ * @param spec - What the node does, from an action factory.
326
+ * @param cases - One arm per declared exit.
327
+ * @param options - Node options, as `.step()`.
328
+ * @returns This builder, so calls chain.
329
+ *
330
+ * @example
331
+ * **Route a human task's outcomes, and converge on one return**
332
+ * ```ts
333
+ * export default flow('draft-review')
334
+ * .output({ summary: types.string })
335
+ * .var('summary', types.string)
336
+ * .stepSwitch('review', hitl({ fields: [], outcomes: ['Publish', 'Revise'] }), [
337
+ * { value: 'Publish', body: (b) => b.step('publish', script({ code: 'return "published";', returns: 'string' }), { updates: { summary: out('publish') } }) },
338
+ * { value: 'Revise', body: (b) => b.step('sendBack', script({ code: 'return "revise";', returns: 'string' }), { updates: { summary: out('sendBack') } }) },
339
+ * ])
340
+ * .return({ summary: v('summary') })
341
+ * .build();
342
+ * ```
343
+ */
344
+ stepSwitch(name, spec, cases, options) {
345
+ if (!Array.isArray(cases) || cases.length === 0) {
346
+ throw new TypeError(`.stepSwitch('${name}'): needs at least one arm. With nothing to route, use .step().`);
347
+ }
348
+ const arms = cases.map((c) => {
349
+ const arm = new ArmBuilder();
350
+ c.body?.(arm);
351
+ // A `.switch()` arm's label becomes `inputs.cases[].label` on the decision
352
+ // node. There is no such slot here: an edge carries no label, and the port
353
+ // is already named by the outcome or branch it leaves. Refusing beats
354
+ // accepting it and dropping it on the floor.
355
+ if (arm.armLabel !== undefined) {
356
+ throw new TypeError(`.stepSwitch('${name}') arm '${c.value}': .label() has nowhere to go — a port edge carries no `
357
+ + `label, and the exit is named by "${c.value}" already. Use options.label to rename the NODE.`);
358
+ }
359
+ return { value: String(c.value), body: arm.steps };
360
+ });
361
+ this.steps.push({
362
+ kind: 'stepSwitch', name, spec: spec, cases: arms,
363
+ ...(options ? { options } : {}),
364
+ });
365
+ return this;
366
+ }
306
367
  /**
307
368
  * Handle the PREVIOUS step's failure: if it fails, the flow runs `bodyFn`'s
308
369
  * steps instead of continuing.
package/dist/serialize.js CHANGED
@@ -14,6 +14,8 @@ import { Expr, toExpr, SCHEDULE_PRESETS, DELAY_PRESETS, parseIxpProjectType, fla
14
14
  import { FlowAction, FlowTrigger } from './core/node-classes.js';
15
15
  import { connectorRawNodeRefusal } from './core/connector-raw-node.js';
16
16
  import { bindingSelfNameMessage } from './core/binding-messages.js';
17
+ import { hitlRoutesPerOutcome } from './core/hitl-routing.js';
18
+ import { declaredExits, hasExits } from './core/step-ports.js';
17
19
  import { buildConfiguration, transportHttpMethod } from './config.js';
18
20
  import { stableId } from './core/stable-id.js';
19
21
  import { readEventFilter, eventFilterProblem, eventFilterJmes, eventFilterTreeLeaf } from './event-filters.js';
@@ -38,6 +40,7 @@ function bodyUsesBreak(steps) {
38
40
  return steps.some((s) => s.kind === 'break'
39
41
  || (s.kind === 'branch' && (bodyUsesBreak(s.then) || bodyUsesBreak(s.otherwise)))
40
42
  || (s.kind === 'switch' && (s.cases.some((c) => bodyUsesBreak(c.body)) || (s.default ? bodyUsesBreak(s.default) : false)))
43
+ || (s.kind === 'stepSwitch' && s.cases.some((c) => bodyUsesBreak(c.body)))
41
44
  || (s.kind === 'parallel' && s.arms.some(bodyUsesBreak))
42
45
  || (s.kind === 'stepToList' && bodyUsesBreak(s.body)));
43
46
  }
@@ -3676,6 +3679,8 @@ function stepNamesOf(steps, out = []) {
3676
3679
  s.cases.forEach((c) => stepNamesOf(c.body, out));
3677
3680
  stepNamesOf(s.default ?? [], out);
3678
3681
  }
3682
+ else if (s.kind === 'stepSwitch')
3683
+ s.cases.forEach((c) => stepNamesOf(c.body, out));
3679
3684
  else if (s.kind === 'parallel')
3680
3685
  s.arms.forEach((a) => stepNamesOf(a, out));
3681
3686
  else if (s.kind === 'loop')
@@ -4982,18 +4987,24 @@ export function serialize(built, opts = {}) {
4982
4987
  const id = stepUid(scope, step.name);
4983
4988
  const hitlType = hitlNodeType(spec.inputs);
4984
4989
  // Rich options select a newer definition, same rule as the scheduled
4985
- // trigger's cron: `outcomePorts` needs 1.1 (per-outcome `outcome-<id>`
4986
- // exits) and `exposeError` needs 1.2 (adds the `error` output; its exits
4987
- // are the same per-outcome handles). Plain tasks stay on the pinned
4988
- // default, byte-identically.
4989
- const outcomeRouted = spec.inputs?.outcomePorts === true || spec.inputs?.exposeError === true;
4990
+ // trigger's cron: per-outcome routing needs 1.1 (per-outcome
4991
+ // `outcome-<id>` exits) and `exposeError` needs 1.2 (adds the `error`
4992
+ // output; its exits are the same per-outcome handles). A zero- or
4993
+ // one-outcome task stays on the pinned default, byte-identically.
4994
+ //
4995
+ // More than one outcome INFERS the routing — see `hitlRoutesPerOutcome`
4996
+ // for the four things that turn that off, one of which is an explicit
4997
+ // `{ version }`, which is what keeps decompile→compile exact.
4998
+ const pinned = step.options?.version;
4999
+ const outcomeRouted = hitlRoutesPerOutcome(spec.inputs, pinned);
4990
5000
  const neededVersion = spec.inputs?.exposeError ? '1.2' : outcomeRouted ? '1.1' : undefined;
4991
5001
  if (neededVersion !== undefined && hitlType !== NODE_TYPE.hitl) {
4992
5002
  throw new Error(`"${step.name}": outcomePorts/exposeError need the base human task's ${neededVersion} `
4993
5003
  + `definition — the '${spec.inputs?.variant}' variant has no per-outcome version. `
4994
5004
  + `Drop the variant, or route on out('${step.name}', 'Action') downstream.`);
4995
5005
  }
4996
- const pinned = step.options?.version;
5006
+ // Both refusals below can only be reached by an EXPLICIT flag: the
5007
+ // inference already declines on a variant and on a pinned version.
4997
5008
  if (neededVersion !== undefined && pinned !== undefined && pinned !== neededVersion) {
4998
5009
  throw new Error(`"${step.name}": ${spec.inputs?.exposeError ? 'exposeError' : 'outcomePorts'} needs the task's ${neededVersion} `
4999
5010
  + `definition, but { version: '${pinned}' } pins it. Drop the pin or match it.`);
@@ -5307,6 +5318,72 @@ export function serialize(built, opts = {}) {
5307
5318
  else if (step.kind === 'stepToList') {
5308
5319
  tails = emitPortList(step, lastAction ?? '?', tails, scope, parentId, breakTarget);
5309
5320
  }
5321
+ else if (step.kind === 'stepSwitch') {
5322
+ // The action, then EVERY exit it declares as an arm — no tacit tail.
5323
+ //
5324
+ // The exits are resolved BEFORE the node is emitted, because a spec with
5325
+ // no routable fan-out (a human task pinned to 1.0, a variant, an http
5326
+ // with no branches) must refuse rather than emit a node and then
5327
+ // discover it has one port. `declaredExits` carries the reason.
5328
+ const exits = declaredExits(step.spec, step.options?.version);
5329
+ if (!hasExits(exits)) {
5330
+ throw new Error(`.stepSwitch("${step.name}"): ${exits.reason}, so there are no exits to route.`
5331
+ + (exits.suggestion ? ` ${exits.suggestion}` : ''));
5332
+ }
5333
+ const action = {
5334
+ kind: 'action', name: step.name, spec: step.spec,
5335
+ ...(step.options ? { options: step.options } : {}),
5336
+ };
5337
+ // The tail `emitAction` hands back is the family's tacit exit — the
5338
+ // primary outcome, http's `default`. Discarded on purpose: every exit is
5339
+ // an arm here, so that port is wired by the arm that names it (and
5340
+ // wiring both is flow-builder-sdk#741).
5341
+ emitAction(action, tails, scope, parentId);
5342
+ applyUpdates(action);
5343
+ applyLabel(action);
5344
+ const nodeId = scope.stepNodeId.get(step.name);
5345
+ if (!nodeId)
5346
+ throw new Error(`.stepSwitch("${step.name}"): emitted node not found.`);
5347
+ const armTails = [];
5348
+ const routed = new Set();
5349
+ for (const c of step.cases) {
5350
+ const exit = exits.find((e) => e.value === c.value);
5351
+ if (!exit) {
5352
+ throw new Error(`.stepSwitch("${step.name}"): no exit named ${JSON.stringify(c.value)}. `
5353
+ + `It declares ${exits.map((e) => JSON.stringify(e.value)).join(', ')}.`);
5354
+ }
5355
+ routed.add(exit.port);
5356
+ // Arms CONVERGE, exactly as `.switch()`'s do: every arm that does not
5357
+ // end in `.return()` contributes its tail, so the next step fans in
5358
+ // from all of them. (`.stepToList` instead gives each arm a private
5359
+ // End, which silently drops the flow's outputs on that path —
5360
+ // flow-builder-sdk#742.)
5361
+ armTails.push(...emit(c.body, [{ node: nodeId, port: exit.port }], scope, parentId, breakTarget));
5362
+ }
5363
+ // A declared exit with no arm gets an End, so the run FINISHES there
5364
+ // rather than reaching a port with no edge and stalling. `check` warns
5365
+ // (STEP_SWITCH_EXIT_UNROUTED) — this is the compile-side half of it.
5366
+ //
5367
+ // `action: 'End'` outcomes get one TOO, and that is deliberate: the
5368
+ // designer draws a handle for every entry in `inputs.schema.outcomes`
5369
+ // whatever its action, so skipping them left a drawn handle wired to
5370
+ // nothing. UiPath/skills' own gate says the same and is stricter than
5371
+ // this was — `assert_outcome_wiring` requires an edge per outcome with
5372
+ // no exemption, and its convention makes every non-primary outcome an
5373
+ // `End`. What the action changes is the WARNING, not the edge: nobody
5374
+ // needs to be told to write an arm for an outcome that ends the run.
5375
+ for (const e of exits) {
5376
+ if (routed.has(e.port))
5377
+ continue;
5378
+ const endId = uid(scope, `${step.name}${e.port.charAt(0).toUpperCase()}${e.port.slice(1)}End`);
5379
+ const end = makeNode(NODE_TYPE.end, endId, 'End', parentId ? { parentId } : {});
5380
+ end.inputs = {};
5381
+ scope.nodes.push(end);
5382
+ addEdge(scope, { node: nodeId, port: e.port }, endId);
5383
+ }
5384
+ tails = armTails;
5385
+ lastAction = step.name;
5386
+ }
5310
5387
  else if (step.kind === 'branch') {
5311
5388
  const id = stepUid(scope, step.name);
5312
5389
  const node = makeNode(NODE_TYPE.decision, id, step.options?.label ?? step.label ?? step.name, { parentId, requestedVersion: step.options?.version });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "5.4.1",
3
+ "version": "6.0.0",
4
4
  "description": "Build UiPath Flow, Case, and BPMN artifacts by writing TypeScript.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.uipath.com/maestro",
@@ -87,5 +87,5 @@
87
87
  "@types/node": "^22.7.0",
88
88
  "esbuild": "^0.28.1"
89
89
  },
90
- "gitref": "b5437630666554168202df6a5fbfe43ee72c43d0"
90
+ "gitref": "8c327b39940d34432128dd713c731027bb00fbde"
91
91
  }