@uipath/maestro-builder-sdk 6.8.2 → 6.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/decompile.js CHANGED
@@ -37,6 +37,7 @@ import { inferScriptShape } from './core/script-return.js';
37
37
  import { DEFAULT_TRIGGER_ID } from './core/expr.js';
38
38
  import { parseIxpProjectType } from './core/actions.js';
39
39
  import { readFlowGlobals } from './flow-variables.js';
40
+ import { OUTCOME_HANDLE_NODE_TYPES } from './core/hitl-routing.js';
40
41
  import { eventFilterFromTreeLeaf } from './event-filters.js';
41
42
  /**
42
43
  * The trigger id of the flow currently being decompiled, so a reference through a
@@ -410,13 +411,43 @@ class Graph {
410
411
  const first = outcomes[0]?.id;
411
412
  return typeof first === 'string' && first !== '' ? `outcome-${first}` : undefined;
412
413
  }
414
+ /**
415
+ * A base or quick-form human task whose EVERY outcome is wired, all to ONE
416
+ * step, with no other exit edge — what `serialize` emits for `.step()` after a
417
+ * task that does not route per outcome (quick-form's default, a pinned 1.0,
418
+ * `outcomePorts: false`), and what the designer saves when every outcome
419
+ * handle is dragged to the same node. It decompiles to a `.step()` with
420
+ * `outcomePorts: false` (2+ outcomes), which recompiles to the same edges.
421
+ *
422
+ * A base task on its 1.1/1.2 definition is left out on purpose: that version
423
+ * IS its routing (`outcomePorts: true` / `exposeError: true` select it), and
424
+ * it already round-trips exactly as a `.stepSwitch` with empty arms.
425
+ */
426
+ convergedFanOut(id) {
427
+ const n = this.byId.get(id);
428
+ if (!n || !OUTCOME_HANDLE_NODE_TYPES.has(String(n.type)) || versionRoutesHitl(n))
429
+ return false;
430
+ const ids = outcomeIdsOfNode(n);
431
+ if (ids.length === 0)
432
+ return false;
433
+ const exits = this.outEdges(id).filter((e) => e.sourcePort !== 'error' && !ARTIFACT_PORTS.has(e.sourcePort));
434
+ if (exits.length !== ids.length)
435
+ return false;
436
+ const ports = new Set(exits.map((e) => e.sourcePort));
437
+ if (!ids.every((i) => ports.has(`outcome-${i}`)))
438
+ return false;
439
+ return new Set(exits.map((e) => e.targetNodeId)).size === 1;
440
+ }
413
441
  /**
414
442
  * Every declared exit of `id`, when ALL of them are wired — the `.stepSwitch`
415
443
  * shape. `undefined` otherwise, which leaves the node with a tacit main path
416
- * and its siblings as side arms.
444
+ * and its siblings as side arms — and for a converged fan-out, whose one
445
+ * target is the main path.
417
446
  */
418
447
  fullyWiredExits(id) {
419
448
  const node = this.byId.get(id);
449
+ if (this.convergedFanOut(id))
450
+ return undefined;
420
451
  const declared = node ? declaredExitsOfNode(node) : undefined;
421
452
  if (declared === undefined || declared.length < 2)
422
453
  return undefined;
@@ -609,7 +640,7 @@ function emitStepSpec(node, imp, inputNames, o, graph) {
609
640
  case T.hitlQuickForm:
610
641
  case T.hitlActionApp:
611
642
  case T.hitlDocValidation:
612
- return emitHitl(node, imp, inputNames);
643
+ return emitHitl(node, imp, inputNames, graph);
613
644
  case T.queueCreate:
614
645
  case T.queueCreateAndWait: {
615
646
  // `itemData` is the node's name for the surface's `item` (the reference
@@ -1320,7 +1351,7 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1320
1351
  * definition's own `inputDefaults.recipient` for delivery. Recovering those
1321
1352
  * would add arguments the author never wrote to every task.
1322
1353
  */
1323
- function emitHitl(node, imp, inputNames) {
1354
+ function emitHitl(node, imp, inputNames, graph) {
1324
1355
  const i = (node.inputs ?? {});
1325
1356
  const schema = (i.schema ?? {});
1326
1357
  const plain = (v) => {
@@ -1417,11 +1448,39 @@ function emitHitl(node, imp, inputNames) {
1417
1448
  ['render', render !== undefined && render !== 'standard' ? str(render) : undefined],
1418
1449
  ['taxonomy', stationExpr(i.taxonomy)],
1419
1450
  ]);
1420
- // Per-outcome exits and the error output are VERSION-selected: 1.1 routes per
1421
- // outcome, 1.2 adds the error output (and implies the routing).
1451
+ // On the base node the VERSION says it: 1.1 routes per outcome, 1.2 adds the
1452
+ // error output (and implies the routing). Everywhere else — quick-form, a
1453
+ // base task on 1.0 — the EDGES say it: outcomes that lead to different places
1454
+ // are routed per outcome (`outcomePorts: true`, which a pinned 1.0 keeps at
1455
+ // 1.0), and outcomes that all lead to one step are a fan-out, which the
1456
+ // task's default recompiles to.
1422
1457
  const version = String(node.typeVersion ?? '');
1423
1458
  const exposeError = version === '1.2';
1424
- const outcomePorts = version === '1.1';
1459
+ const outEdges = graph?.outEdges(node.id) ?? [];
1460
+ const derivesHandles = OUTCOME_HANDLE_NODE_TYPES.has(String(node.type));
1461
+ const outcomePorts = (node.type === T.hitl && version === '1.1')
1462
+ || (derivesHandles && !versionRoutesHitl(node) && graph !== undefined && !graph.convergedFanOut(node.id)
1463
+ && outEdges.some((e) => e.sourcePort.startsWith('outcome-')));
1464
+ // A lone `completed` edge on a task with several outcomes is the shape the
1465
+ // designer stopped drawing: before flow-workbench #3512 (09-08) it was
1466
+ // unconditioned, so every outcome continued down it; since then Studio Web and
1467
+ // the converter remap it to the primary outcome only. Recompiling writes one
1468
+ // `outcome-<id>` edge per outcome to the same step — the first meaning — so
1469
+ // this is the one place decompile→compile is deliberately not exact. Say so.
1470
+ const legacyCompleted = derivesHandles && outcomeIdsOfNode(node).length > 1
1471
+ && outEdges.some((e) => e.sourcePort === 'completed');
1472
+ // Both a converged fan-out and a legacy lone `completed` mean "every outcome
1473
+ // continues to the next step". Say so in the source with `outcomePorts: false`
1474
+ // rather than leaving it to the `{ version }` pin, so the intent survives an
1475
+ // author dropping the pin.
1476
+ const fanOut = !outcomePorts && derivesHandles && !versionRoutesHitl(node) && outcomeIdsOfNode(node).length > 1
1477
+ && (legacyCompleted || graph?.convergedFanOut(node.id) === true);
1478
+ if (legacyCompleted) {
1479
+ warnOnce(`human task ${str(node.id)} leaves on the legacy 'completed' handle, which Studio Web no longer draws `
1480
+ + `once a task has outcomes (it reads the edge as the primary outcome only). Recompiling emits one `
1481
+ + `outcome-<id> edge per outcome to the same step, so every outcome continues — what the edge meant `
1482
+ + `before flow-workbench #3512.`);
1483
+ }
1425
1484
  const title = plain(i.title);
1426
1485
  const priority = plain(i.priority);
1427
1486
  return `${imp.use('hitl')}(${opts([
@@ -1438,7 +1497,7 @@ function emitHitl(node, imp, inputNames) {
1438
1497
  ['fields', fields.length ? `[${fields.join(', ')}]`
1439
1498
  : (variant === 'action-app' || variant === 'document-validation') ? undefined : '[]'],
1440
1499
  ['outcomes', `[${outcomes.join(', ')}]`],
1441
- ['outcomePorts', outcomePorts ? 'true' : undefined],
1500
+ ['outcomePorts', outcomePorts ? 'true' : fanOut ? 'false' : undefined],
1442
1501
  ['exposeError', exposeError ? 'true' : undefined],
1443
1502
  ])})`;
1444
1503
  }
@@ -2081,7 +2140,9 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2081
2140
  // A per-outcome human task routes each NON-primary outcome from its own
2082
2141
  // `outcome-<slug>` exit; the primary one continues the main path, so it is
2083
2142
  // already a successor (see Graph.primaryOutcomePort).
2084
- {
2143
+ // A converged fan-out has none: every outcome continues to the one
2144
+ // successor, which is what `.step()` recompiles to.
2145
+ if (!ctx.graph.convergedFanOut(node.id)) {
2085
2146
  const primary = ctx.graph.primaryOutcomePort(node.id);
2086
2147
  for (const oe of ctx.graph.outEdges(node.id)
2087
2148
  .filter((e) => e.sourcePort.startsWith('outcome-') && e.sourcePort !== primary)) {
@@ -2165,18 +2226,28 @@ function portArmExits(node, ctx) {
2165
2226
  return undefined;
2166
2227
  return declaredExitsOfNode(node);
2167
2228
  }
2229
+ /** A base human task on its 1.1/1.2 definition, where the VERSION selects per-outcome routing. */
2230
+ function versionRoutesHitl(node) {
2231
+ const version = String(node.typeVersion ?? '');
2232
+ return node.type === T.hitl && (version === '1.1' || version === '1.2');
2233
+ }
2234
+ /** The outcome ids in a human task's `inputs.schema.outcomes`. */
2235
+ function outcomeIdsOfNode(node) {
2236
+ const outcomes = (node.inputs?.schema?.outcomes ?? []);
2237
+ return outcomes.map((o) => o?.id).filter((id) => typeof id === 'string' && id !== '');
2238
+ }
2168
2239
  /**
2169
2240
  * The exits a node declares, read back off the EMITTED artifact rather than off
2170
- * an authored spec — `inputs.schema.outcomes` for a human task at 1.1/1.2,
2171
- * `inputs.branches` plus `default` for an HTTP call. The authoring-side twin is
2172
- * `declaredExits` in `core/step-ports.ts`; they answer about different inputs,
2173
- * which is why this is not a call into it.
2241
+ * an authored spec — `inputs.schema.outcomes` for a base or quick-form human
2242
+ * task (the product draws one `outcome-<id>` handle per outcome on both, at
2243
+ * every version), `inputs.branches` plus `default` for an HTTP call. The
2244
+ * authoring-side twin is `declaredExits` in `core/step-ports.ts`; they answer
2245
+ * about different inputs, which is why this is not a call into it.
2174
2246
  */
2175
2247
  function declaredExitsOfNode(node) {
2176
2248
  const inputs = (node.inputs ?? {});
2177
2249
  if (String(node.type).startsWith('uipath.human-in-the-loop')) {
2178
- const version = String(node.typeVersion ?? '');
2179
- if (version !== '1.1' && version !== '1.2')
2250
+ if (!OUTCOME_HANDLE_NODE_TYPES.has(String(node.type)))
2180
2251
  return undefined;
2181
2252
  const outcomes = (inputs.schema?.outcomes ?? []);
2182
2253
  if (outcomes.length === 0)
package/dist/serialize.js CHANGED
@@ -14,8 +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
+ import { OUTCOME_HANDLE_NODE_TYPES, assertHitlExitInvariant, hitlExitModel, hitlExitViolation, outcomeSlug } from './core/hitl-routing.js';
18
+ import { declaredExits, hasExits, stopsAtEnd } from './core/step-ports.js';
19
19
  import { buildConfiguration, transportHttpMethod } from './config.js';
20
20
  import { stableId } from './core/stable-id.js';
21
21
  import { readEventFilter, eventFilterProblem, eventFilterJmes, eventFilterTreeLeaf } from './event-filters.js';
@@ -2793,14 +2793,13 @@ function bindingPath(value, rename) {
2793
2793
  // `__hitlInputBindings` the designer reads back stores the binding verbatim.
2794
2794
  return path.startsWith('vars.') ? path : `vars.${path}`;
2795
2795
  }
2796
- /** `Approve` → `approve`; a name that slugifies to nothing falls back to its index. */
2796
+ /**
2797
+ * `Approve` → `approve`; a name that slugifies to nothing falls back to its index.
2798
+ * The same rule that names the `outcome-<id>` ports (`outcomeSlug`), so an id
2799
+ * this writes into `schema.outcomes` is always one a port can name.
2800
+ */
2797
2801
  function outcomeId(name, index) {
2798
- const slug = String(name ?? '')
2799
- .trim()
2800
- .toLowerCase()
2801
- .replace(/[^a-z0-9]+/g, '-')
2802
- .replace(/^-+|-+$/g, '');
2803
- return slug || `outcome-${index + 1}`;
2802
+ return outcomeSlug(name, index);
2804
2803
  }
2805
2804
  /**
2806
2805
  * A deterministic UUID for the form schema, derived from the flow and node ids.
@@ -3859,7 +3858,28 @@ export function serialize(built, opts = {}) {
3859
3858
  scope.stepNodeId.set(stepName, id);
3860
3859
  return id;
3861
3860
  }
3861
+ /**
3862
+ * Base and quick-form human tasks emitted so far, by node id. `addEdge` holds
3863
+ * every edge out of one to the product's handle rule (`hitlExitViolation`), so
3864
+ * `completed` on a task with outcomes fails at the edge that would write it;
3865
+ * `assertHitlExitInvariant` repeats the check over each finished scope for the
3866
+ * edges that do not come through here (parked `.stepToRef` rejoins).
3867
+ */
3868
+ const hitlNodes = new Map();
3869
+ /**
3870
+ * Human tasks with an outcome whose path stops at an End while another
3871
+ * outcome continues — read by `.parallel`, whose join would wait forever for
3872
+ * the continuing path (check's HITL_END_OUTCOME_IN_PARALLEL). Two ways in: a
3873
+ * fan-out task's `action: 'End'` outcome, and a `.stepSwitch` outcome with no
3874
+ * arm (it gets an End) or whose arm ends in `.return()`. `why` finishes the
3875
+ * sentence "human task X …" in the refusal.
3876
+ */
3877
+ const hitlEndExits = [];
3862
3878
  function addEdge(scope, from, toNode, toPort = 'input') {
3879
+ const hitlNode = hitlNodes.get(from.node);
3880
+ const refused = hitlNode ? hitlExitViolation(hitlNode, from.port) : undefined;
3881
+ if (refused)
3882
+ throw new Error(`serialize: ${refused}`);
3863
3883
  scope.edges.push({ id: `edge_${edgeSeq++}`, sourceNodeId: from.node, sourcePort: from.port, targetNodeId: toNode, targetPort: toPort });
3864
3884
  }
3865
3885
  let col = 0;
@@ -3959,6 +3979,7 @@ export function serialize(built, opts = {}) {
3959
3979
  for (const vd of child.vars)
3960
3980
  globals.push({ id: vd.name, name: vd.name, direction: 'inout', type: vd.type, ...varMeta(vd) });
3961
3981
  resolveRejoins(scope);
3982
+ assertHitlExitInvariant(scope.nodes, scope.edges);
3962
3983
  typeAgentInputSchemas(scope, globals);
3963
3984
  emitStickyNotes(child, scope);
3964
3985
  const layoutEdges = applyAuthorLayout(child.layout, scope, `subflow "${nodeId}"`);
@@ -4077,13 +4098,11 @@ export function serialize(built, opts = {}) {
4077
4098
  // the shipped `sample-flows/queue-operations.flow` wires its edges to it.
4078
4099
  if (type === NODE_TYPE.queueCreate || type === NODE_TYPE.queueCreateAndWait)
4079
4100
  return 'success';
4080
- // The human task's definition declares exactly one source handle, `completed`
4081
- // — it fires when the person submits, whichever outcome they chose. (Per-outcome
4082
- // handles exist only from typeVersion 1.1 of the node, which this SDK does not
4083
- // emit; routing on the outcome is a decision downstream, reading `output.Action`,
4084
- // which is also how flow-v1's own outcome conditions are written.)
4085
- // — and all three human-task node types declare the same one, so the port is
4086
- // the same whichever `variant` was named.
4101
+ // Every human-task definition declares one static source handle, `completed`.
4102
+ // It is the real exit only where the product draws it: action-app,
4103
+ // document-validation, and a task with no outcomes. A base or quick-form task
4104
+ // with outcomes leaves on `outcome-<id>` instead (`hitlExitModel`), and never
4105
+ // reaches this function for its tail.
4087
4106
  if (type === NODE_TYPE.hitl || type === NODE_TYPE.hitlQuickForm || type === NODE_TYPE.hitlActionApp
4088
4107
  || type === NODE_TYPE.hitlDocValidation)
4089
4108
  return 'completed';
@@ -5094,28 +5113,31 @@ export function serialize(built, opts = {}) {
5094
5113
  if (spec.kind === 'hitl') {
5095
5114
  const id = stepUid(scope, step.name);
5096
5115
  const hitlType = hitlNodeType(spec.inputs);
5097
- // Rich options select a newer definition, same rule as the scheduled
5098
- // trigger's cron: per-outcome routing needs 1.1 (per-outcome
5099
- // `outcome-<id>` exits) and `exposeError` needs 1.2 (adds the `error`
5100
- // output; its exits are the same per-outcome handles). A zero- or
5101
- // one-outcome task stays on the pinned default, byte-identically.
5116
+ // How the task leaves its node — `hitlExitModel` (core/hitl-routing.ts)
5117
+ // has the product rule and what `.step()` means after each shape.
5102
5118
  //
5103
- // More than one outcome INFERS the routing — see `hitlRoutesPerOutcome`
5104
- // for the four things that turn that off, one of which is an explicit
5105
- // `{ version }`, which is what keeps decompile→compile exact.
5119
+ // Only the BASE node has definition versions to select: routing per
5120
+ // outcome picks 1.1 and `exposeError` picks 1.2 (which adds the `error`
5121
+ // output). quick-form draws the same per-outcome handles on the one
5122
+ // definition it has, so it routes per outcome without moving. An explicit
5123
+ // pin wins over the inferred 1.1 — `flow validate` accepts `outcome-<id>`
5124
+ // edges on base@1.0 — but not over `exposeError`, whose output only 1.2
5125
+ // declares. A zero- or one-outcome task stays on the pinned default.
5106
5126
  const pinned = step.options?.version;
5107
- const outcomeRouted = hitlRoutesPerOutcome(spec.inputs, pinned);
5108
- const neededVersion = spec.inputs?.exposeError ? '1.2' : outcomeRouted ? '1.1' : undefined;
5109
- if (neededVersion !== undefined && hitlType !== NODE_TYPE.hitl) {
5110
- throw new Error(`"${step.name}": outcomePorts/exposeError need the base human task's ${neededVersion} `
5111
- + `definition — the '${spec.inputs?.variant}' variant has no per-outcome version. `
5112
- + `Drop the variant, or route on out('${step.name}', 'Action') downstream.`);
5127
+ const exitModel = hitlExitModel(spec.inputs, pinned);
5128
+ if (hitlType !== NODE_TYPE.hitl && spec.inputs?.exposeError === true) {
5129
+ throw new Error(`"${step.name}": exposeError needs the base human task's 1.2 definition, which adds the `
5130
+ + `error output — the '${spec.inputs?.variant}' variant has no such version. Drop the variant or exposeError.`);
5113
5131
  }
5114
- // Both refusals below can only be reached by an EXPLICIT flag: the
5115
- // inference already declines on a variant and on a pinned version.
5116
- if (neededVersion !== undefined && pinned !== undefined && pinned !== neededVersion) {
5117
- throw new Error(`"${step.name}": ${spec.inputs?.exposeError ? 'exposeError' : 'outcomePorts'} needs the task's ${neededVersion} `
5118
- + `definition, but { version: '${pinned}' } pins it. Drop the pin or match it.`);
5132
+ if (spec.inputs?.outcomePorts === true && !OUTCOME_HANDLE_NODE_TYPES.has(hitlType)) {
5133
+ throw new Error(`"${step.name}": outcomePorts: true, but the '${spec.inputs?.variant}' variant reads its outcomes `
5134
+ + `from its app and leaves on its single 'completed' exit. Route on out('${step.name}', 'Action') downstream.`);
5135
+ }
5136
+ const neededVersion = hitlType !== NODE_TYPE.hitl ? undefined
5137
+ : spec.inputs?.exposeError ? '1.2' : exitModel.shape === 'per-outcome' ? '1.1' : undefined;
5138
+ if (spec.inputs?.exposeError === true && pinned !== undefined && pinned !== '1.2') {
5139
+ throw new Error(`"${step.name}": exposeError needs the task's 1.2 definition, but { version: '${pinned}' } pins it. `
5140
+ + `Drop the pin or match it.`);
5119
5141
  }
5120
5142
  const node = makeNode(hitlType, id, step.name, { parentId, requestedVersion: pinned ?? neededVersion });
5121
5143
  const { inputs, outputs } = hitlInputs(spec.inputs, id, built.id, step.name, scope.rename);
@@ -5124,15 +5146,19 @@ export function serialize(built, opts = {}) {
5124
5146
  // gives their shape (see hitlInputs), so the instance's version wins.
5125
5147
  node.outputs = outputs;
5126
5148
  scope.nodes.push(node);
5149
+ if (OUTCOME_HANDLE_NODE_TYPES.has(hitlType))
5150
+ hitlNodes.set(id, node);
5127
5151
  for (const t of tails)
5128
5152
  addEdge(scope, t, id);
5129
- if (outcomeRouted) {
5130
- // 1.1+ has no `completed` handle — each outcome is its own exit. The
5131
- // PRIMARY (first) outcome continues the main path; the author routes
5132
- // the rest with .stepToList('outcome-<id>', …).
5133
- const first = spec.inputs.outcomes?.[0];
5134
- const firstName = typeof first === 'string' ? first : first?.name;
5135
- return [{ node: id, port: `outcome-${outcomeId(firstName, 0)}` }];
5153
+ if (exitModel.shape === 'per-outcome') {
5154
+ // The PRIMARY (first) outcome continues the main path; the author routes
5155
+ // the rest with .stepToList('outcome-<id>', …) or as .stepSwitch arms.
5156
+ return [{ node: id, port: exitModel.exits[0]?.port ?? `outcome-${outcomeSlug(undefined, 0)}` }];
5157
+ }
5158
+ if (exitModel.shape === 'every-outcome') {
5159
+ // Fan-out: every outcome continues to the next step, each on its own
5160
+ // handle. End outcomes are marked, and `emit` ends them.
5161
+ return exitModel.exits.map((e) => ({ node: id, port: e.port, ...(e.endsProcess ? { endsRun: true } : {}) }));
5136
5162
  }
5137
5163
  return [{ node: id, port: successPort(hitlType) }];
5138
5164
  }
@@ -5370,6 +5396,34 @@ export function serialize(built, opts = {}) {
5370
5396
  * are backward — every deployed rejoin is forward convergence, which is why
5371
5397
  * `check` refuses a backward one outright.
5372
5398
  */
5399
+ /**
5400
+ * An End node on one exit of `nodeId`, so the run FINISHES there — a
5401
+ * `.stepSwitch` exit with no arm, and a fan-out human task's `action: 'End'`
5402
+ * outcome.
5403
+ */
5404
+ function endExit(scope, nodeId, port, stepName, parentId) {
5405
+ const endId = uid(scope, `${stepName}${port.charAt(0).toUpperCase()}${port.slice(1)}End`);
5406
+ const end = makeNode(NODE_TYPE.end, endId, 'End', parentId ? { parentId } : {});
5407
+ end.inputs = {};
5408
+ scope.nodes.push(end);
5409
+ addEdge(scope, { node: nodeId, port }, endId);
5410
+ }
5411
+ /**
5412
+ * Refuse a side exit on a human task's port that the step's tails already
5413
+ * continue from: that would put two edges on one port (flow-builder-sdk#741).
5414
+ * After a fan-out task that is EVERY outcome port. `check` reports the same
5415
+ * as HITL_OUTCOME_PORTS_OFF; this is the compile-side half.
5416
+ */
5417
+ function refuseRewire(method, port, source, tails) {
5418
+ if (!hitlNodes.has(tails[0]?.node ?? '') || !tails.some((t) => t.port === port))
5419
+ return;
5420
+ throw new Error(tails.length > 1
5421
+ ? `${method}('${port}', …) after human task "${source}": every outcome already continues to the next `
5422
+ + `step, '${port}' included, because this task does not route per outcome. Add outcomePorts: true `
5423
+ + `(the first outcome then continues and the others are routed), or route every outcome with .stepSwitch.`
5424
+ : `${method}('${port}', …) after human task "${source}": '${port}' already continues the main path, so `
5425
+ + `routing it here too would put two edges on one port (flow-builder-sdk#741).`);
5426
+ }
5373
5427
  /**
5374
5428
  * A step list hanging off a named PORT of the step just emitted.
5375
5429
  *
@@ -5379,12 +5433,14 @@ export function serialize(built, opts = {}) {
5379
5433
  */
5380
5434
  function emitPortList(step, source, tails, scope, parentId, breakTarget) {
5381
5435
  const handler = step.body;
5382
- // `emitAction` returns exactly one tail for every action kind — the node's
5383
- // own success port. Anything else means a new action shape reached here
5384
- // without being taught about error handling; say so rather than guess.
5385
- if (tails.length !== 1) {
5386
- throw new Error(`.stepToList('${step.port}', …) after step "${source}": expected the step to leave exactly one outgoing port, got ${tails.length}.`);
5436
+ // `emitAction` returns one tail for every action kind — the node's own
5437
+ // success port — except a fan-out human task, which returns one per outcome
5438
+ // from the SAME node. Tails from two nodes mean a new action shape reached
5439
+ // here without being taught about side exits; say so rather than guess.
5440
+ if (tails.length === 0 || tails.some((t) => t.node !== tails[0].node)) {
5441
+ throw new Error(`.stepToList('${step.port}', …) after step "${source}": expected the step to leave from exactly one node, got ${tails.length} tail(s).`);
5387
5442
  }
5443
+ refuseRewire('.stepToList', step.port, source, tails);
5388
5444
  const nodeId = tails[0].node;
5389
5445
  const node = scope.nodes.find((n) => n.id === nodeId);
5390
5446
  if (!node)
@@ -5451,12 +5507,33 @@ export function serialize(built, opts = {}) {
5451
5507
  throw new Error(`label on "${step.name}": emitted node not found.`);
5452
5508
  node.display = { ...(node.display ?? {}), label };
5453
5509
  };
5454
- for (const step of steps) {
5510
+ for (const [stepIndex, step] of steps.entries()) {
5455
5511
  if (step.kind === 'action') {
5456
5512
  tails = emitAction(step, tails, scope, parentId);
5457
5513
  applyUpdates(step);
5458
5514
  applyLabel(step);
5459
5515
  lastAction = step.name;
5516
+ // A fan-out human task's `action: 'End'` outcomes end the run here: the
5517
+ // flow runtime never reads `action`, so only an End node can. When the
5518
+ // very next step is a `.return()` they join it instead — it is an End
5519
+ // already, and it writes the flow's return values on that path too.
5520
+ if (tails.some((t) => t.endsRun)) {
5521
+ // Side exits attach to the task; the step that CONTINUES it is the
5522
+ // first one after them (check's HITL_UNREACHABLE_AFTER reads the same).
5523
+ const joinsReturn = steps.slice(stepIndex + 1)
5524
+ .find((x) => x.kind !== 'stepToList' && x.kind !== 'stepToRef')?.kind === 'return';
5525
+ const continuing = [];
5526
+ for (const t of tails) {
5527
+ if (t.endsRun && !joinsReturn)
5528
+ endExit(scope, t.node, t.port, step.name, parentId);
5529
+ else
5530
+ continuing.push({ node: t.node, port: t.port });
5531
+ }
5532
+ if (!joinsReturn && continuing.length > 0) {
5533
+ hitlEndExits.push({ scope, parentId, step: step.name, why: "ends the run on an action: 'End' outcome" });
5534
+ }
5535
+ tails = continuing;
5536
+ }
5460
5537
  }
5461
5538
  else if (step.kind === 'stepToList') {
5462
5539
  tails = emitPortList(step, lastAction ?? '?', tails, scope, parentId, breakTarget);
@@ -5489,6 +5566,9 @@ export function serialize(built, opts = {}) {
5489
5566
  throw new Error(`.stepSwitch("${step.name}"): emitted node not found.`);
5490
5567
  const armTails = [];
5491
5568
  const routed = new Set();
5569
+ // Outcomes of a human task whose path stops at an End: an arm that ends
5570
+ // in `.return()` here, an outcome with no arm below.
5571
+ const stopped = [];
5492
5572
  for (const c of step.cases) {
5493
5573
  const exit = exits.find((e) => e.value === c.value);
5494
5574
  if (!exit) {
@@ -5501,7 +5581,12 @@ export function serialize(built, opts = {}) {
5501
5581
  // from all of them. (`.stepToList` instead gives each arm a private
5502
5582
  // End, which silently drops the flow's outputs on that path —
5503
5583
  // flow-builder-sdk#742.)
5504
- armTails.push(...emit(c.body, [{ node: nodeId, port: exit.port }], scope, parentId, breakTarget));
5584
+ const caseTails = emit(c.body, [{ node: nodeId, port: exit.port }], scope, parentId, breakTarget);
5585
+ if (caseTails.length === 0 && stopsAtEnd(c.body))
5586
+ stopped.push(String(c.value));
5587
+ armTails.push(...(hitlNodes.has(nodeId)
5588
+ ? caseTails.map((t) => (t.humanSwitch ? t : { ...t, humanSwitch: step.name }))
5589
+ : caseTails));
5505
5590
  }
5506
5591
  // A declared exit with no arm gets an End, so the run FINISHES there
5507
5592
  // rather than reaching a port with no edge and stalling. `check` warns
@@ -5516,13 +5601,20 @@ export function serialize(built, opts = {}) {
5516
5601
  // `End`. What the action changes is the WARNING, not the edge: nobody
5517
5602
  // needs to be told to write an arm for an outcome that ends the run.
5518
5603
  for (const e of exits) {
5519
- if (routed.has(e.port))
5520
- continue;
5521
- const endId = uid(scope, `${step.name}${e.port.charAt(0).toUpperCase()}${e.port.slice(1)}End`);
5522
- const end = makeNode(NODE_TYPE.end, endId, 'End', parentId ? { parentId } : {});
5523
- end.inputs = {};
5524
- scope.nodes.push(end);
5525
- addEdge(scope, { node: nodeId, port: e.port }, endId);
5604
+ if (!routed.has(e.port)) {
5605
+ endExit(scope, nodeId, e.port, step.name, parentId);
5606
+ stopped.push(String(e.value));
5607
+ }
5608
+ }
5609
+ // On a human task those Ends sit beside the arms that continue, and only
5610
+ // one outcome fires. Inside a `.parallel` arm the join then waits
5611
+ // forever for the continuing path, as for an `action: 'End'` outcome.
5612
+ if (hitlNodes.has(nodeId) && stopped.length > 0 && armTails.length > 0) {
5613
+ hitlEndExits.push({
5614
+ scope, parentId, step: step.name,
5615
+ why: `stops at an End on outcome${stopped.length === 1 ? '' : 's'} ${stopped.join(', ')} of its `
5616
+ + `.stepSwitch (no arm, or an arm that ends in .return())`,
5617
+ });
5526
5618
  }
5527
5619
  tails = armTails;
5528
5620
  lastAction = step.name;
@@ -5590,9 +5682,49 @@ export function serialize(built, opts = {}) {
5590
5682
  // tail and so contributes no incoming edge; that is rejected at rung 1
5591
5683
  // too (PARALLEL_ARM_RETURNS), since a BPMN join waits for every branch —
5592
5684
  // UNLESS no arm reaches the join at all, which is the next paragraph.
5685
+ // The join waits for a token on EVERY incoming edge, and a fan-out human
5686
+ // task's outcome edges are mutually exclusive — only one fires. So two
5687
+ // of them straight into the join, or an End outcome beside a continuing
5688
+ // one, hang the run with no incident. check reports both
5689
+ // (HITL_FAN_OUT_INTO_JOIN, HITL_END_OUTCOME_IN_PARALLEL); this is the
5690
+ // compile-side half, and it fails closed.
5593
5691
  const armTails = [];
5594
- for (const arm of step.arms)
5595
- armTails.push(...emit(arm, tails, scope, parentId, breakTarget));
5692
+ const endsBefore = hitlEndExits.length;
5693
+ for (const arm of step.arms) {
5694
+ const t = emit(arm, tails, scope, parentId, breakTarget);
5695
+ // An arm hands the join ONE edge, or several that are alternatives —
5696
+ // `.branch` / `.switch` / `.stepSwitch` arms, a fan-out task's
5697
+ // outcomes — of which only one fires. Refused when any of them is a
5698
+ // human task's outcome edge: however the task got there (at the arm's
5699
+ // end, one empty `.stepSwitch` arm beside a body, one side of a
5700
+ // `.branch`), the join waits for all of them. Rejoining arms with no
5701
+ // human task in them are the older, general case (still emitted).
5702
+ const outcomeTail = t.find((x) => hitlNodes.has(x.node) && x.port.startsWith('outcome-'));
5703
+ // The same holds when each outcome's arm ended on a step of its own:
5704
+ // a complete `.stepSwitch` with nothing after it hands the join one
5705
+ // edge per arm, and still only one arm runs.
5706
+ const switchTail = outcomeTail ? undefined : t.find((x) => x.humanSwitch !== undefined);
5707
+ if (t.length >= 2 && switchTail) {
5708
+ throw new Error(`.parallel("${step.name}"): an arm sends ${t.length} alternative edges into join "${step.name}" — `
5709
+ + `among them the end of an arm of human task "${switchTail.humanSwitch}"'s .stepSwitch — and only one `
5710
+ + `of them fires, while the join waits for all of them. Add a step at the end of the arm, after the `
5711
+ + `.stepSwitch, so its arms meet there first, or move the task out of the .parallel.`);
5712
+ }
5713
+ if (t.length >= 2 && outcomeTail) {
5714
+ throw new Error(`.parallel("${step.name}"): an arm sends ${t.length} alternative edges into join "${step.name}" — `
5715
+ + `among them outcome edge "${outcomeTail.port}" of human task "${outcomeTail.node}" — and only one `
5716
+ + `of them fires, while the join waits for all of them. Add a step at the end of the arm so its paths `
5717
+ + `meet there first, or move the task out of the .parallel.`);
5718
+ }
5719
+ armTails.push(...t);
5720
+ }
5721
+ const starved = hitlEndExits.slice(endsBefore).find((x) => x.scope === scope && x.parentId === parentId);
5722
+ if (starved && armTails.length > 0) {
5723
+ throw new Error(`.parallel("${step.name}"): human task "${starved.step}" ${starved.why} inside an arm, and join `
5724
+ + `"${step.name}" would wait forever for the path that continues. Give every outcome a path that `
5725
+ + `continues to the end of the arm (drop action: 'End', add the missing .stepSwitch arm, move the `
5726
+ + `.return() after the .parallel), or move the task out of the .parallel.`);
5727
+ }
5596
5728
  // When EVERY arm is terminal — a terminate, or an End — there is nothing
5597
5729
  // left to join, and emitting a Merge would add a node with no incoming
5598
5730
  // edge, which is unreachable. The platform's own flows agree:
@@ -5714,6 +5846,7 @@ export function serialize(built, opts = {}) {
5714
5846
  const from = tails[0];
5715
5847
  if (!from)
5716
5848
  throw new Error(`.stepToRef('${step.port}', '${step.target}') has no step to leave from.`);
5849
+ refuseRewire('.stepToRef', step.port, lastAction ?? '?', tails);
5717
5850
  if (step.port === 'error') {
5718
5851
  const node = scope.nodes.find((n) => n.id === from.node);
5719
5852
  if (node)
@@ -5866,6 +5999,7 @@ export function serialize(built, opts = {}) {
5866
5999
  }
5867
6000
  }
5868
6001
  resolveRejoins(root);
6002
+ assertHitlExitInvariant(root.nodes, root.edges);
5869
6003
  typeAgentInputSchemas(root, globals);
5870
6004
  emitStickyNotes(built, root);
5871
6005
  const rootLayoutEdges = applyAuthorLayout(built.layout, root, `flow "${built.id}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "6.8.2",
3
+ "version": "6.8.4",
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": "34be5c196660ad049240339a969f508f67898e19"
90
+ "gitref": "7620c5d1340ead9f1ef6a5eaff9da5317e0756f1"
91
91
  }