@uipath/maestro-builder-sdk 6.8.3 → 6.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/check.js CHANGED
@@ -11,8 +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
+ import { hitlDerivesOutcomeHandles, hitlExitModel, hitlRoutesPerOutcome } from './core/hitl-routing.js';
15
+ import { declaredExits, hasExits, stopsAtEnd } from './core/step-ports.js';
16
16
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
17
17
  import { compareSchemaVersions, schemaVersionRefusal } from './schema-version.js';
18
18
  import { prepareCommand } from './core/cli-spelling.js';
@@ -107,14 +107,16 @@ function checkFlow(built, ancestors, opts = {}) {
107
107
  const hitlOutcomeSteps = new Map();
108
108
  const hitlOutcomeRouted = new Map();
109
109
  // The other half of that bookkeeping: hitl steps that declare a CHOICE (more
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 chosen — by 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).
110
+ // than one outcome) and do not route it, so every outcome goes to the same
111
+ // place — the same next step (a base or quick-form task that does not route
112
+ // per outcome), or the one `completed` exit (an app-backed variant). Since
113
+ // more than one outcome infers per-outcome routing on the base node, reaching
114
+ // this map means that was chosen — by a variant, a pinned version, or
115
+ // `outcomePorts: false` — and WHICH decides what the remedy can be. Whether
116
+ // it is a mistake at all depends on something no single step can see —
117
+ // whether anything downstream reads the chosen outcome — so the decision
118
+ // waits until the expressions are collected (see HITL_OUTCOMES_UNREACHABLE
119
+ // below).
118
120
  const hitlChoiceNoPorts = new Map();
119
121
  // Data Fabric writes that target a record `{ fromRead: '<step>' }` — update and
120
122
  // delete share the selector, so they share the check.
@@ -179,6 +181,30 @@ function checkFlow(built, ancestors, opts = {}) {
179
181
  step: term.name,
180
182
  });
181
183
  }
184
+ // Nothing may follow a human task whose EVERY outcome ends the run, except a
185
+ // `.return()`. Each outcome gets its own End node — the flow runtime never
186
+ // reads `action`, so only the graph can end the run there — which leaves
187
+ // the next step with no incoming edge. A `.return()` is an End already, so
188
+ // the outcomes join it. Side exits attach to the task and do not count.
189
+ for (const [i, st] of steps.entries()) {
190
+ if (st.kind !== 'action' || st.spec.kind !== 'hitl')
191
+ continue;
192
+ const model = hitlExitModel(st.spec.inputs, st.options?.version);
193
+ if (model.shape !== 'every-outcome' || !model.exits.every((e) => e.endsProcess))
194
+ continue;
195
+ const after = steps.slice(i + 1).find((x) => x.kind !== 'stepToList' && x.kind !== 'stepToRef');
196
+ if (after === undefined || after.kind === 'return')
197
+ continue;
198
+ diags.push({
199
+ level: 'error',
200
+ code: 'HITL_UNREACHABLE_AFTER',
201
+ message: `Every outcome of human task "${st.name}" is action: 'End' `
202
+ + `(${model.exits.map((e) => e.value).join(', ')}), so each one ends the run at its own End node, and `
203
+ + `"${'name' in after ? after.name : after.kind}" after it can never run. Drop action: 'End' from the `
204
+ + 'outcomes that should continue, or end the path here with .return().',
205
+ step: st.name,
206
+ });
207
+ }
182
208
  // The action a port-scoped edge leaves, tracked because `stepToList` and
183
209
  // `stepToRef` FOLLOW the step they attach to rather than nesting inside it.
184
210
  let prevAction;
@@ -224,41 +250,35 @@ function checkFlow(built, ancestors, opts = {}) {
224
250
  });
225
251
  }
226
252
  }
227
- // The mirror of HITL_OUTCOME_PORTS_OFF below: `completed` on a human task
228
- // that routes per outcome.
253
+ // The mirror of HITL_OUTCOME_PORTS_OFF below: `completed` on a base or
254
+ // quick-form human task that has outcomes.
229
255
  //
230
- // Per-outcome routing moves the step to the node's 1.1/1.2 definition, and
231
- // those declare ONE source handle — `outcome-{item.id}`, repeated over
232
- // `inputs.schema.outcomes`. There is no `completed` handle to leave from,
233
- // so this edge is refused by the product: `flow validate` answers "Edge
234
- // references undeclared source handle \"completed\" on node \"<step>\""
235
- // (measured). Without this rule `check` was silent, `compile` emitted it,
236
- // and the author found out from validate — or from a consumer that
237
- // inspects ports.
238
- //
239
- // Since more than one outcome INFERS the routing, this is now also the
240
- // rule an author meets when they write the old shape on a multi-outcome
241
- // task, so the message names the two ways back to a `completed` exit.
256
+ // The product draws one `outcome-<id>` handle per outcome on those two node
257
+ // types, at every definition version, and `completed` only when there are
258
+ // none (flow-workbench `build-handle-customization.ts`). An edge on it is
259
+ // stale: Studio Web and the converter remap it to the PRIMARY outcome
260
+ // (#3512), so every other outcome loses its path — and `flow validate`
261
+ // accepts it without `handleCustomization`, so nothing later catches it.
262
+ // `serialize` refuses to emit one; this says why, in the author's terms.
242
263
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port === 'completed') {
243
264
  const prevSpec = prevAction?.spec;
244
265
  const isHitl = !!prevSpec && 'kind' in prevSpec && prevSpec.kind === 'hitl';
245
- const routed = isHitl && hitlRoutesPerOutcome(prevSpec.inputs, prevAction?.options?.version);
246
- if (routed) {
247
- const first = prevSpec.inputs.outcomes?.[0];
248
- const firstId = outcomeSlug(typeof first === 'string' ? first : first?.name, 0);
249
- const why = prevSpec.inputs?.exposeError === true ? 'exposeError selects'
250
- : prevSpec.inputs?.outcomePorts === true ? 'outcomePorts selects'
251
- : `its ${(prevSpec.inputs?.outcomes ?? []).length} outcomes select`;
266
+ const model = isHitl ? hitlExitModel(prevSpec.inputs, prevAction?.options?.version) : undefined;
267
+ if (model && model.shape !== 'placeholder') {
268
+ const ports = model.exits.map((e) => `"${e.port}"`).join(', ');
269
+ const routed = model.shape === 'per-outcome';
252
270
  diags.push({
253
271
  level: 'error', code: 'HITL_COMPLETED_PORT_GONE', step: prevAction?.name,
254
- message: `Port "completed" does not exist on human task "${prevAction?.name}". ${why} the node's `
255
- + `${prevSpec.inputs?.exposeError === true ? '1.2' : '1.1'} definition, whose only source handle is `
256
- + `"outcome-<id>" repeated over the outcomes — the 1.0 definition's single 'completed' exit is not `
257
- + `part of it. The FIRST outcome ("outcome-${firstId}") is what continues the main path.`,
258
- suggestion: prevSpec.inputs?.outcomePorts === true || prevSpec.inputs?.exposeError === true
259
- ? `route the outcomes (.stepToList('outcome-${firstId}', …)), or drop outcomePorts to keep the 'completed' exit`
260
- : `route the outcomes (.stepToList('outcome-${firstId}', …)), or keep the single exit with `
261
- + `outcomePorts: false`,
272
+ message: `Port "completed" does not exist on human task "${prevAction?.name}". It has outcomes, so `
273
+ + `the product draws one handle per outcome (${ports}) and no 'completed' — an edge there is `
274
+ + `remapped to the primary outcome and the others lose their path. `
275
+ + (routed
276
+ ? `The FIRST outcome ("${model.exits[0]?.port}") is what continues the main path.`
277
+ : `.step() after this task already continues every outcome to the next step.`),
278
+ suggestion: routed
279
+ ? `route the outcomes (.stepToList('${model.exits[1]?.port ?? model.exits[0]?.port}', …)), or continue `
280
+ + `every outcome to the next step with outcomePorts: false`
281
+ : `drop the 'completed' route — .step() continues every outcome — or route each outcome with .stepSwitch`,
262
282
  });
263
283
  }
264
284
  }
@@ -273,29 +293,20 @@ function checkFlow(built, ancestors, opts = {}) {
273
293
  + `${prevAction ? `"${prevAction.name}" ` : ''}is not a human task.`,
274
294
  });
275
295
  }
276
- else if (!hitlRoutesPerOutcome(prevSpec.inputs, prevAction?.options?.version)) {
277
- // Reachable three ways now that more than one outcome infers the
278
- // routing: `outcomePorts: false`, a pinned `{ version: '1.0' }`, or a
279
- // variant that has no per-outcome definition to select.
280
- const off = prevSpec.inputs?.outcomePorts === false ? 'outcomePorts: false turns them off'
281
- : prevSpec.inputs?.variant !== undefined
282
- ? `the '${prevSpec.inputs.variant}' variant has no per-outcome definition version`
283
- : prevAction?.options?.version !== undefined
284
- ? `{ version: '${prevAction.options.version}' } pins the definition that has none`
285
- : 'it declares fewer than two outcomes';
286
- diags.push({
287
- level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
288
- message: `Port "${s.port}" routes a per-outcome exit, but human task "${prevAction?.name}" `
289
- + `does not have them — ${off}, so its single exit is 'completed'. Per-outcome exits are the `
290
- + `node's 1.1 definition.`,
291
- suggestion: prevSpec.inputs?.variant !== undefined
292
- ? `route on out('${prevAction?.name}', 'Action') downstream`
293
- : `outcomePorts: true`,
294
- });
295
- }
296
296
  else {
297
+ const model = hitlExitModel(prevSpec.inputs, prevAction?.options?.version);
297
298
  const ids = (prevSpec.inputs.outcomes ?? []).map((o, i) => outcomeSlug(typeof o === 'string' ? o : o?.name, i));
298
- if (!ids.includes(want)) {
299
+ if (model.shape === 'placeholder') {
300
+ diags.push({
301
+ level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
302
+ message: `Port "${s.port}" routes a per-outcome exit, but human task "${prevAction?.name}" `
303
+ + `has none — ${ids.length === 0 ? 'it declares no outcomes'
304
+ : `the '${prevSpec.inputs?.variant}' variant reads its outcomes from its app`}, so its single `
305
+ + `exit is 'completed'.`,
306
+ suggestion: `route on out('${prevAction?.name}', 'Action') downstream`,
307
+ });
308
+ }
309
+ else if (!ids.includes(want)) {
299
310
  diags.push({
300
311
  level: 'error', code: 'HITL_OUTCOME_PORT_UNKNOWN', step: prevAction?.name,
301
312
  message: `Port "${s.port}" routes the outcome id ${JSON.stringify(want)}, but human task `
@@ -303,6 +314,19 @@ function checkFlow(built, ancestors, opts = {}) {
303
314
  + `outcome names slugified (lowercase, non-alphanumerics to '-').`,
304
315
  });
305
316
  }
317
+ else if (model.shape === 'every-outcome') {
318
+ // The port is real, but it is ALREADY wired: a task that does not
319
+ // route per outcome continues every outcome to the next step, so a
320
+ // second route would put two edges on one port (#741).
321
+ diags.push({
322
+ level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
323
+ message: `Port "${s.port}" is already wired: human task "${prevAction?.name}" does not route per `
324
+ + `outcome — ${fanOutWhy(model.why, prevAction?.options?.version)} — so .step() continues every `
325
+ + `outcome to the next step, "${s.port}" included. A second route would put two edges on one port.`,
326
+ suggestion: `outcomePorts: true (then the first outcome continues and the others are routed), `
327
+ + `or route every outcome with .stepSwitch`,
328
+ });
329
+ }
306
330
  else if (prevAction) {
307
331
  let routed = hitlOutcomeRouted.get(prevAction.name);
308
332
  if (!routed)
@@ -384,10 +408,13 @@ function checkFlow(built, ancestors, opts = {}) {
384
408
  // the whole truth there, and the rule below would be a false positive.
385
409
  const allEnd = outcomes.length > 0
386
410
  && outcomes.every((o) => typeof o !== 'string' && o?.action === 'End');
387
- if (outcomes.length > 1 && !allEnd
411
+ // `.stepSwitch` routes every outcome as an arm, so it never belongs here.
412
+ if (outcomes.length > 1 && !allEnd && s0.kind !== 'stepSwitch'
388
413
  && !hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
389
414
  hitlChoiceNoPorts.set(s.name, {
390
415
  names: outcomes.map((o) => String((typeof o === 'string' ? o : o?.name) ?? '').trim()),
416
+ ends: outcomes.filter((o) => typeof o !== 'string' && o?.action === 'End')
417
+ .map((o) => String(o.name ?? '').trim()),
391
418
  variant: s.spec.inputs?.variant,
392
419
  pinned: s.options?.version,
393
420
  portsOff: s.spec.inputs?.outcomePorts === false,
@@ -3747,15 +3774,19 @@ function nearestInterval(every) {
3747
3774
  * is why the e2e task's covering assertion is structural, and why these are
3748
3775
  * rung-1 errors rather than something the ladder catches.
3749
3776
  *
3750
- * There is deliberately NO rule requiring an arm to contribute exactly one edge
3751
- * (i.e. no rejecting a `.branch` inside an arm because its two mutually
3752
- * exclusive paths both wire into the Merge). BPMN's parallel-gateway join waits
3753
- * for a token per incoming flow, which reads like a deadlock — but the
3754
- * platform's own canonical emitter does precisely this: it rejoins
3755
- * every `if/else` and every `switch` arm on a `core.logic.merge`, wiring the
3756
- * mutually exclusive `false` port straight into it. Those flows deploy and run.
3757
- * A checker that banned the shape would be inventing a constraint the platform
3758
- * does not have.
3777
+ * The join is a `bpmn:parallelGateway`, and it waits for a token on EVERY
3778
+ * incoming flow (flow-v1 `gateway-join-tracker.ts:33-36`). So two mutually
3779
+ * exclusive edges into it — a `.branch` / `.switch` / `.stepSwitch` inside an
3780
+ * arm whose paths both end at the Merge — leave the join waiting for the path
3781
+ * that was not taken, and the run stays Running with no incident. That was
3782
+ * measured live on 09-01 (a Merge fed by a Decision's two ports). An older note
3783
+ * here said such flows "deploy and run"; they deploy, and they do not finish.
3784
+ * This function does NOT refuse that general shape yet — it predates the human
3785
+ * task rule below and is tracked as its own SDK issue. It does refuse it when a
3786
+ * human task's outcome edge is among the arm's alternative edges into the join
3787
+ * — a fan-out task at the arm's end, one empty `.stepSwitch` arm beside a body
3788
+ * arm, a task on one side of a `.branch` (HITL_FAN_OUT_INTO_JOIN) — and an
3789
+ * `action: 'End'` outcome inside an arm (HITL_END_OUTCOME_IN_PARALLEL).
3759
3790
  */
3760
3791
  function checkParallel(s, diags) {
3761
3792
  const at = (code, message, suggestion) => {
@@ -3798,6 +3829,14 @@ function checkParallel(s, diags) {
3798
3829
  + `terminate its own arm, or move the earlier step(s) before the \`.parallel(…)\` call.`, `.parallel('${s.name}', [(a) => a.terminate('${term.name}'), …])`);
3799
3830
  }
3800
3831
  });
3832
+ arms.forEach((arm, i) => {
3833
+ const steps = Array.isArray(arm) ? arm : [];
3834
+ const fanIn = hitlFanInto(steps);
3835
+ if (fanIn)
3836
+ diags.push(fanInDiag(s.name, i, steps, fanIn));
3837
+ for (const h of hitlEndHazards(steps))
3838
+ diags.push(endDiag(s.name, h));
3839
+ });
3801
3840
  // An arm that ends the flow reaches an End node instead of the Merge, so it
3802
3841
  // contributes NO incoming edge. Verified on the emitted graph: with two arms
3803
3842
  // and one returning, the Merge has a single incoming edge and is no longer a
@@ -3821,6 +3860,180 @@ function checkParallel(s, diags) {
3821
3860
  });
3822
3861
  }
3823
3862
  }
3863
+ /**
3864
+ * The edges a step list hands on, mirroring serialize's `emit` tail for tail.
3865
+ *
3866
+ * `.branch` / `.switch` / `.stepSwitch` hand on every arm's tails (an empty arm
3867
+ * hands on the port it started from), a fan-out human task one per continuing
3868
+ * outcome, a routed one its primary outcome; `.return` / `.terminate` / `.break`
3869
+ * and a `stepToRef('output', …)` hand on none, `.stepToList` leaves the path as
3870
+ * it was, and every other step hands on its own single edge.
3871
+ */
3872
+ function armTails(steps, incoming) {
3873
+ let tails = incoming;
3874
+ for (const st of steps) {
3875
+ if (st.kind === 'stepToList')
3876
+ continue;
3877
+ if (st.kind === 'stepToRef') {
3878
+ if (st.port === 'output')
3879
+ tails = [];
3880
+ continue;
3881
+ }
3882
+ if (st.kind === 'return' || st.kind === 'terminate' || st.kind === 'break')
3883
+ return [];
3884
+ if (st.kind === 'action') {
3885
+ tails = actionTails(st.name, st.spec, st.options?.version);
3886
+ }
3887
+ else if (st.kind === 'stepSwitch') {
3888
+ const spec = st.spec;
3889
+ const outcomes = spec.kind === 'hitl' && hitlDerivesOutcomeHandles(spec.inputs?.variant);
3890
+ tails = st.cases.flatMap((c) => armTails(c.body, [{ from: st.name, ...(outcomes ? { outcome: String(c.value) } : {}) }])
3891
+ .map((t) => (outcomes && !t.humanSwitch ? { ...t, humanSwitch: { step: st.name, outcome: String(c.value) } } : t)));
3892
+ }
3893
+ else if (st.kind === 'branch') {
3894
+ tails = [...armTails(st.then, [{ from: st.name }]), ...armTails(st.otherwise, [{ from: st.name }])];
3895
+ }
3896
+ else if (st.kind === 'switch') {
3897
+ tails = [
3898
+ ...st.cases.flatMap((c) => armTails(c.body, [{ from: st.name }])),
3899
+ ...(st.default !== undefined ? armTails(st.default, [{ from: st.name }]) : []),
3900
+ ];
3901
+ }
3902
+ else {
3903
+ tails = [{ from: st.name ?? st.kind }];
3904
+ }
3905
+ }
3906
+ return tails;
3907
+ }
3908
+ function actionTails(name, spec, version) {
3909
+ if (spec.kind !== 'hitl' || !hitlDerivesOutcomeHandles(spec.inputs?.variant))
3910
+ return [{ from: name }];
3911
+ const model = hitlExitModel(spec.inputs, version);
3912
+ if (model.shape === 'placeholder')
3913
+ return [{ from: name }];
3914
+ if (model.shape === 'per-outcome')
3915
+ return model.exits.slice(0, 1).map((e) => ({ from: name, outcome: e.value }));
3916
+ return model.exits.filter((e) => !e.endsProcess).map((e) => ({ from: name, outcome: e.value }));
3917
+ }
3918
+ /**
3919
+ * The tails of a `.parallel` arm when they would starve its join: two or more
3920
+ * alternative edges — only one fires — at least one of which is a human task's
3921
+ * outcome edge, or the end of a human-task `.stepSwitch` arm. The join waits
3922
+ * for a token on every incoming flow.
3923
+ *
3924
+ * However the task got there, it is the same graph: a fan-out task at the arm's
3925
+ * end (its outcomes are the alternatives), a `.stepSwitch` with an empty arm
3926
+ * beside a body arm, a complete `.stepSwitch` with nothing after it (one edge
3927
+ * per arm's last step), a task on one side of a `.branch`. serialize refuses the
3928
+ * same condition, edge for edge. Rejoining arms with NO human task in them are
3929
+ * the older, general case, and not refused here.
3930
+ */
3931
+ function hitlFanInto(steps) {
3932
+ const tails = armTails(steps, [{ from: '(fork)' }]);
3933
+ return tails.length >= 2 && tails.some((t) => t.outcome !== undefined || t.humanSwitch !== undefined) ? tails : undefined;
3934
+ }
3935
+ function fanInDiag(join, arm, steps, tails) {
3936
+ const tagged = tails.find((t) => t.outcome !== undefined || t.humanSwitch !== undefined);
3937
+ const task = tagged.outcome !== undefined ? tagged.from : tagged.humanSwitch.step;
3938
+ if (tails.every((t) => t.from === task && t.outcome !== undefined)) {
3939
+ return {
3940
+ level: 'error', code: 'HITL_FAN_OUT_INTO_JOIN', step: task,
3941
+ message: `Human task "${task}" ends its path inside parallel "${join}", so its outcomes `
3942
+ + `${tails.map((t) => t.outcome).join(', ')} each get their own edge into the join. Only one outcome ever `
3943
+ + `fires, and the join waits for a token on every incoming edge, so the run never gets past "${join}" — with `
3944
+ + `no incident, and \`flow validate\` answers Valid. Add a step after "${task}" inside the arm, so every `
3945
+ + `outcome continues to that step and only its one edge reaches the join (the decision is `
3946
+ + `out('${task}', 'Action')), or move the task out of the \`.parallel(…)\`.`,
3947
+ suggestion: `a.step('${task}', hitl(…)).step('record', …)`,
3948
+ };
3949
+ }
3950
+ const edges = tails.map((t) => (t.outcome !== undefined ? `outcome ${t.outcome} of "${t.from}"`
3951
+ : t.humanSwitch ? `"${t.from}" (on outcome ${t.humanSwitch.outcome} of "${t.humanSwitch.step}")` : `"${t.from}"`));
3952
+ const last = [...steps].reverse().find((x) => x.kind !== 'stepToList' && x.kind !== 'stepToRef');
3953
+ return {
3954
+ level: 'error', code: 'HITL_FAN_OUT_INTO_JOIN', step: task,
3955
+ message: `Parallel "${join}" arm ${arm + 1} ends on ${tails.length} alternative paths, and each gets its own edge `
3956
+ + `into the join: ${edges.join(', ')}. Only one of them runs, and the join waits for a token on every incoming `
3957
+ + `edge, so the run never gets past "${join}" — with no incident, and \`flow validate\` answers Valid. Add a `
3958
+ + `step at the end of the arm${last?.name ? `, after "${last.name}"` : ''}, so the paths meet on that step and `
3959
+ + `only its one edge reaches the join, or move human task "${task}" out of the \`.parallel(…)\`.`,
3960
+ suggestion: `….step('record', …) // last step of arm ${arm + 1}`,
3961
+ };
3962
+ }
3963
+ /**
3964
+ * Human tasks in one `.parallel` arm that continue every outcome while some of
3965
+ * them are `action: 'End'`. The End outcome's path stops at its End node, so the
3966
+ * join waits for the continuing path that did not run.
3967
+ *
3968
+ * Recurses into `.branch` / `.switch` / `.stepSwitch` bodies, which reach the
3969
+ * join too. Not into a loop body (the loop node is what reaches the join) or a
3970
+ * nested `.parallel` (its own join is checked on its own).
3971
+ */
3972
+ function hitlEndHazards(steps) {
3973
+ const found = [];
3974
+ for (const st of steps) {
3975
+ if (st.kind === 'action' && st.spec.kind === 'hitl') {
3976
+ const model = hitlExitModel(st.spec.inputs, st.options?.version);
3977
+ if (model.shape !== 'every-outcome')
3978
+ continue;
3979
+ const ending = model.exits.filter((e) => e.endsProcess).map((e) => e.value);
3980
+ if (ending.length > 0 && ending.length < model.exits.length)
3981
+ found.push({ step: st.name, outcomes: ending, via: 'action-end' });
3982
+ }
3983
+ else if (st.kind === 'branch') {
3984
+ found.push(...hitlEndHazards(st.then), ...hitlEndHazards(st.otherwise));
3985
+ }
3986
+ else if (st.kind === 'switch') {
3987
+ for (const c of st.cases)
3988
+ found.push(...hitlEndHazards(c.body));
3989
+ if (st.default)
3990
+ found.push(...hitlEndHazards(st.default));
3991
+ }
3992
+ else if (st.kind === 'stepSwitch') {
3993
+ // An outcome with no arm gets an End (serialize's `endExit`), and so does
3994
+ // an arm that ends in `.return()`. Beside an arm that continues, that End
3995
+ // is where this arm's token stops on that outcome.
3996
+ const spec = st.spec;
3997
+ const exits = spec.kind === 'hitl' && hitlDerivesOutcomeHandles(spec.inputs?.variant)
3998
+ ? declaredExits(st.spec, st.options?.version) : undefined;
3999
+ if (exits && hasExits(exits)) {
4000
+ const stopped = exits.filter((e) => {
4001
+ const c = st.cases.find((x) => x.value === e.value);
4002
+ return !c || (armTails(c.body, [{ from: st.name }]).length === 0 && stopsAtEnd(c.body));
4003
+ }).map((e) => e.value);
4004
+ if (stopped.length > 0 && armTails([st], [{ from: '(fork)' }]).length > 0) {
4005
+ found.push({ step: st.name, outcomes: stopped, via: 'step-switch' });
4006
+ }
4007
+ }
4008
+ for (const c of st.cases)
4009
+ found.push(...hitlEndHazards(c.body));
4010
+ }
4011
+ }
4012
+ return found;
4013
+ }
4014
+ function endDiag(join, h) {
4015
+ const list = h.outcomes.join(', ');
4016
+ if (h.via === 'step-switch') {
4017
+ return {
4018
+ level: 'error', code: 'HITL_END_OUTCOME_IN_PARALLEL', step: h.step,
4019
+ message: `Human task "${h.step}" is a .stepSwitch inside parallel "${join}", and ${h.outcomes.length === 1
4020
+ ? `outcome ${list} has` : `outcomes ${list} have`} no arm, or an arm that ends in .return(). That path `
4021
+ + `stops at an End node, while the join waits for a token from this arm on the path that continues, so the `
4022
+ + `run never gets past "${join}" — with no incident, and \`flow validate\` answers Valid. Give every outcome `
4023
+ + `an arm that continues (and move a .return() after the \`.parallel(…)\`), or move the task out of the `
4024
+ + `\`.parallel(…)\`.`,
4025
+ suggestion: `{ value: '${h.outcomes[0]}', body: (b) => b.step(…) }`,
4026
+ };
4027
+ }
4028
+ return {
4029
+ level: 'error', code: 'HITL_END_OUTCOME_IN_PARALLEL', step: h.step,
4030
+ message: `Human task "${h.step}" is inside parallel "${join}", and ${h.outcomes.length === 1
4031
+ ? `outcome ${list} is` : `outcomes ${list} are`} action: 'End'. `
4032
+ + `That path stops at its own End node, while the join waits for a token from this arm on its `
4033
+ + `continuing path, so the run never gets past "${join}". Drop action: 'End' there and act on `
4034
+ + `out('${h.step}', 'Action') after the join, or move the task out of the \`.parallel(…)\`.`,
4035
+ };
4036
+ }
3824
4037
  /**
3825
4038
  * Diagnose a `transform` step.
3826
4039
  *
@@ -4059,34 +4272,46 @@ function outcomeSlug(name, index) {
4059
4272
  .replace(/^-+|-+$/g, '');
4060
4273
  return slug || `outcome-${index + 1}`;
4061
4274
  }
4275
+ /**
4276
+ * Why a base or quick-form task continues every outcome to the next step, as a
4277
+ * clause for a diagnostic. `pinned` is the step's `{ version }`.
4278
+ */
4279
+ function fanOutWhy(why, pinned) {
4280
+ switch (why) {
4281
+ case 'outcomePorts: false': return 'outcomePorts: false says so';
4282
+ case 'quick-form': return 'a quick-form task does not unless it sets outcomePorts: true';
4283
+ case 'pinned': return `{ version: '${pinned}' } pins it without outcomePorts: true`;
4284
+ case 'one outcome': return 'it has a single outcome';
4285
+ case 'every outcome ends': return "every outcome is action: 'End'";
4286
+ }
4287
+ }
4062
4288
  /**
4063
4289
  * HITL_OUTCOMES_UNREACHABLE — a human task offers the reviewer a CHOICE that
4064
4290
  * nothing downstream can act on.
4065
4291
  *
4066
- * The single-exit shape publishes the outcomes and routes none of them: the 1.0
4067
- * definition's ONE source handle is `completed`, so the outcomes reach the
4068
- * reviewer and `inputs.schema.outcomes`, and then every button the reviewer can
4069
- * press leaves the node the same way. Two outcomes, one path — an approval whose
4070
- * approve and reject arms are indistinguishable is a modelling error, not a
4071
- * style choice, yet it compiles and `flow validate` answers `Valid`
4292
+ * A task that does not route per outcome sends every outcome to the same place:
4293
+ * on the base node and quick-form, one `outcome-<id>` edge per outcome, all to
4294
+ * the same next step; on an app-backed variant, its one `completed` exit. The
4295
+ * outcomes reach the reviewer and `inputs.schema.outcomes`, and then every
4296
+ * button the reviewer can press leads to the same step. Two outcomes, one path —
4297
+ * an approval whose approve and reject arms are indistinguishable is a modelling
4298
+ * error, not a style choice, yet it compiles and `flow validate` answers `Valid`
4072
4299
  * (flow-builder-sdk#735).
4073
4300
  *
4074
4301
  * On a plain base task that is no longer reachable: more than one outcome now
4075
4302
  * INFERS per-outcome routing (`hitlRoutesPerOutcome`, #739), because a warning
4076
4303
  * alone did not change what agents emitted — #739 measured the diagnostic
4077
4304
  * firing three times and the unreachable artifact shipping anyway. What is left
4078
- * for this rule is the cases where the single exit is CHOSEN and the choice may
4079
- * still be a mistake: a `variant` (no per-outcome definition version exists to
4080
- * select, #729), a pinned `{ version: '1.0' }`, and an explicit
4081
- * `outcomePorts: false`.
4305
+ * for this rule is the cases where not routing is CHOSEN and the choice may
4306
+ * still be a mistake: quick-form's default, a pinned `{ version }`, an explicit
4307
+ * `outcomePorts: false`, and the app-backed variants.
4082
4308
  *
4083
- * In all three the shape is correct when something reads the chosen outcome:
4084
- * `out('<step>', 'Action')` feeding a `.switch()` routes the decision as data,
4085
- * and it is the ONLY shape the quick-form, action-app and document-validation
4086
- * variants have. So the rule is not "outcomes without ports", which would
4087
- * refuse that shape; it is "outcomes that no exit and no expression
4088
- * distinguishes". That is why it runs here, after `walk`, rather than inside
4089
- * `checkHitl`: the deciding evidence is the rest of the flow.
4309
+ * In all of them the shape is correct when something reads the chosen outcome:
4310
+ * `out('<step>', 'Action')` feeding a `.switch()` routes the decision as data.
4311
+ * So the rule is not "outcomes without routes", which would refuse that shape;
4312
+ * it is "outcomes that no route and no expression distinguishes". That is why it
4313
+ * runs here, after `walk`, rather than inside `checkHitl`: the deciding evidence
4314
+ * is the rest of the flow.
4090
4315
  *
4091
4316
  * A read counts when it can SEE the outcome — `.output.Action`, the whole
4092
4317
  * answer object (`out('<step>')` / `$vars.<step>.output`, which carries
@@ -4116,35 +4341,46 @@ function reportUnreachableHitlOutcomes(choices, exprs, diags) {
4116
4341
  for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)\s*\[/g))
4117
4342
  readsOutcome.add(m[1]);
4118
4343
  }
4119
- for (const [step, { names, variant, pinned, portsOff }] of choices) {
4344
+ for (const [step, { names, ends, variant, pinned, portsOff }] of choices) {
4120
4345
  if (readsOutcome.has(step))
4121
4346
  continue;
4122
4347
  const ids = names.map((n, i) => outcomeSlug(n, i));
4123
- // Why this task kept the single exit decides which remedy is even legal:
4124
- // `outcomePorts: true` is refused on a variant and conflicts with a pin, so
4125
- // offering it there would trade a warning for a compile error.
4126
- // On a base task with no pin, more than one outcome would have inferred the
4127
- // routing — so the only way such a task reaches here is `outcomePorts:
4128
- // false`, and it is the only case where offering the flag is legal.
4129
- const canTakePorts = variant === undefined && pinned === undefined && portsOff === true;
4130
- const kept = variant !== undefined
4131
- ? `The "${variant}" variant has no per-outcome definition version to select`
4132
- : pinned !== undefined
4133
- ? `{ version: '${pinned}' } pins the definition whose only exit is 'completed'`
4134
- : `outcomePorts: false keeps the single exit`;
4348
+ // The base node and quick-form draw a handle per outcome, so both can route
4349
+ // it — `.stepSwitch`, or `outcomePorts: true` (a pin keeps its version). An
4350
+ // app-backed variant cannot: the Action read is its only remedy.
4351
+ const canRoute = hitlDerivesOutcomeHandles(variant);
4352
+ // On those two, an `action: 'End'` outcome gets its own End node, so the
4353
+ // graph already tells it apart. Only the outcomes that CONTINUE share a step.
4354
+ const continuing = canRoute ? names.filter((n) => !ends.includes(n)) : names;
4355
+ if (continuing.length < 2)
4356
+ continue;
4357
+ const endNote = canRoute && ends.length > 0
4358
+ ? ` (${ends.join(', ')} ${ends.length === 1 ? 'is' : 'are'} action: 'End' and end${ends.length === 1 ? 's' : ''} the run instead)`
4359
+ : '';
4360
+ const kept = !canRoute
4361
+ ? `The "${variant}" variant reads its outcomes from its app and leaves on its single 'completed' exit`
4362
+ : portsOff
4363
+ ? `outcomePorts: false continues every outcome to the next step`
4364
+ : variant === 'quick-form'
4365
+ ? `A quick-form task continues every outcome to the next step unless it routes them`
4366
+ : `{ version: '${pinned}' } without outcomePorts: true continues every outcome to the next step`;
4135
4367
  diags.push({
4136
4368
  level: 'warning', code: 'HITL_OUTCOMES_UNREACHABLE', step,
4137
4369
  message: `Human task "${step}" declares ${names.length} outcomes (${names.join(', ')}), but nothing `
4138
- + `downstream tells them apart: the node emits its single 'completed' exit and every outcome leaves `
4139
- + `on it, and no expression reads out('${step}', 'Action') either. The outcomes reach the reviewer `
4140
- + `and the artifact; the decision does not reach the flow. ${kept}, so switch on `
4141
- + `out('${step}', 'Action') to route the decision as data`
4142
- + (canTakePorts
4143
- ? `, or drop outcomePorts: false for per-outcome exits (${ids.map((i) => `'outcome-${i}'`).join(', ')}).`
4370
+ + `downstream tells them apart: ${canRoute
4371
+ ? `${ends.length > 0 ? `${continuing.join(', ')} continue` : 'every outcome continues'} to the same step${endNote}`
4372
+ : "the node leaves on its single 'completed' exit"}, and no expression reads `
4373
+ + `out('${step}', 'Action') either. The outcomes reach the reviewer and the artifact; the decision `
4374
+ + `does not reach the flow. ${kept}, so switch on out('${step}', 'Action') to route the decision as data`
4375
+ + (canRoute
4376
+ ? `, or give each outcome its own route (${ids.map((i) => `'outcome-${i}'`).join(', ')}) with `
4377
+ + `.stepSwitch${portsOff ? ' or by dropping outcomePorts: false' : ' or outcomePorts: true'}.`
4144
4378
  : '.'),
4145
- suggestion: canTakePorts
4379
+ suggestion: portsOff
4146
4380
  ? 'drop outcomePorts: false'
4147
- : `.switch('route', out('${step}', 'Action'), …)`,
4381
+ : canRoute
4382
+ ? `.stepSwitch('${step}', …), or outcomePorts: true`
4383
+ : `.switch('route', out('${step}', 'Action'), …)`,
4148
4384
  });
4149
4385
  }
4150
4386
  }
@@ -4174,12 +4410,19 @@ function checkHitl(step, inputs, diags) {
4174
4410
  }
4175
4411
  const isApp = variant === 'action-app';
4176
4412
  const isDocval = variant === 'document-validation';
4177
- // Per-outcome ports and the error output are the BASE node's 1.1/1.2
4178
- // definitions; no sub-typed variant has a per-outcome version to select.
4179
- if ((inputs?.outcomePorts === true || inputs?.exposeError === true) && variant !== undefined) {
4180
- at('HITL_OUTCOME_PORTS_VARIANT', `Human task "${step}" asks for ${inputs?.exposeError ? 'exposeError' : 'outcomePorts'}, but its `
4181
- + `variant is "${variant}" — per-outcome exits exist only on the base node type's 1.1/1.2 `
4182
- + `definitions.`, `Omit \`variant\`, or route on out('${step}', 'Action') downstream.`);
4413
+ // The error output is the BASE node's 1.2 definition, which no variant has.
4414
+ // Per-outcome exits are not a version question: quick-form draws one handle
4415
+ // per outcome like the base node, so it may ask for them; the two app-backed
4416
+ // variants read their outcomes from the app and leave on `completed`.
4417
+ if (variant !== undefined
4418
+ && (inputs?.exposeError === true || (inputs?.outcomePorts === true && !hitlDerivesOutcomeHandles(variant)))) {
4419
+ at('HITL_OUTCOME_PORTS_VARIANT', inputs?.exposeError === true
4420
+ ? `Human task "${step}" asks for exposeError, but its variant is "${variant}" — the error output `
4421
+ + `exists only on the base node type's 1.2 definition.`
4422
+ : `Human task "${step}" asks for outcomePorts, but its variant is "${variant}", which reads its `
4423
+ + `outcomes from its app and leaves on its single 'completed' exit.`, inputs?.exposeError === true
4424
+ ? 'Omit `variant`, or drop exposeError.'
4425
+ : `Route on out('${step}', 'Action') downstream.`);
4183
4426
  return;
4184
4427
  }
4185
4428
  if (inputs?.document !== undefined && !isDocval) {