@uipath/maestro-builder-sdk 6.8.3 → 6.8.5

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,11 +11,11 @@ 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
- import { prepareCommand } from './core/cli-spelling.js';
18
+ import { prepareCommand, resolveFlagArg } from './core/cli-spelling.js';
19
19
  export function check(built, opts = {}) {
20
20
  return checkFlow(built, [], opts);
21
21
  }
@@ -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,
@@ -1237,6 +1264,9 @@ function checkFlow(built, ancestors, opts = {}) {
1237
1264
  function checkConnectorLookups(step, spec, opts, diags) {
1238
1265
  const specs = opts.lookups?.(spec.key, spec.action, spec.version, spec.object);
1239
1266
  const resolutions = opts.resolutions ?? {};
1267
+ // One LOOKUP_LITERAL_ID per field: a multiselect of three pasted ids is one
1268
+ // mistake with one remedy, not three.
1269
+ const literalWarned = new Set();
1240
1270
  const visit = (value, path) => {
1241
1271
  if (isLookupToken(value)) {
1242
1272
  // A match key the field does not declare is certainly wrong, and the
@@ -1277,7 +1307,17 @@ function checkConnectorLookups(step, spec, opts, diags) {
1277
1307
  return;
1278
1308
  }
1279
1309
  if (Array.isArray(value)) {
1280
- value.forEach((v, i) => visit(v, [...path, String(i)]));
1310
+ // An element is named by the WIRE name the lookup table is keyed on —
1311
+ // `users[*]`, `contacts[*].id` — not by its index. `users.0` matched no
1312
+ // spec, so a pasted id, a token with a bad match key, or a runtime value
1313
+ // inside a multiselect passed every rule below unseen. A name that already
1314
+ // is the array's (a flat `'users[*]'` key, or a field typed as an array)
1315
+ // stays as it is.
1316
+ const last = path[path.length - 1] ?? '';
1317
+ const element = last.endsWith('[*]') || specs?.[path.join('.')]
1318
+ ? path
1319
+ : [...path.slice(0, -1), `${last}[*]`];
1320
+ value.forEach((v) => visit(v, element));
1281
1321
  return;
1282
1322
  }
1283
1323
  if (value && typeof value === 'object' && !(value instanceof Expr)) {
@@ -1302,6 +1342,9 @@ function checkConnectorLookups(step, spec, opts, diags) {
1302
1342
  const literal = value instanceof Expr ? value.value : value;
1303
1343
  if (typeof literal !== 'string')
1304
1344
  return;
1345
+ if (literalWarned.has(field))
1346
+ return;
1347
+ literalWarned.add(field);
1305
1348
  const { target, call } = lookupPhrases(field, lookup, spec);
1306
1349
  diags.push({
1307
1350
  level: 'warning',
@@ -1313,7 +1356,7 @@ function checkConnectorLookups(step, spec, opts, diags) {
1313
1356
  + `compile nor deploy can tell. Write what you mean and resolve it late:\n`
1314
1357
  + ` ${call}\n`
1315
1358
  + `then record the value once with \`${prepareCommand(spec.key)} `
1316
- + `${spec.action} --resolve ${field}:${lookup.by[0] ?? 'id'}=<value>\`.`,
1359
+ + `${spec.action} --resolve ${resolveFlagArg(field, lookup.by[0] ?? 'id', '<value>')}\`.`,
1317
1360
  step,
1318
1361
  });
1319
1362
  };
@@ -1530,7 +1573,7 @@ function runtimeLookupDiagnostic(step, spec, field, lookup, value) {
1530
1573
  + `neither compile nor validate can tell. Resolve the value you know instead:\n`
1531
1574
  + ` ${call}\n`
1532
1575
  + `then record it once with \`${prepareCommand(spec.key, spec.action)} `
1533
- + `--resolve ${field}:${lookup.by[0] ?? 'id'}=<value>\`; or read the id from a step whose `
1576
+ + `--resolve ${resolveFlagArg(field, lookup.by[0] ?? 'id', '<value>')}\`; or read the id from a step whose `
1534
1577
  + `output returned one (\`${lookup.value}\`).`
1535
1578
  + (isEmail
1536
1579
  ? `\n\`${single.raw}\` reads \`${leaf}\`, an e-mail address, not a \`${target}\`.`
@@ -3747,15 +3790,19 @@ function nearestInterval(every) {
3747
3790
  * is why the e2e task's covering assertion is structural, and why these are
3748
3791
  * rung-1 errors rather than something the ladder catches.
3749
3792
  *
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.
3793
+ * The join is a `bpmn:parallelGateway`, and it waits for a token on EVERY
3794
+ * incoming flow (flow-v1 `gateway-join-tracker.ts:33-36`). So two mutually
3795
+ * exclusive edges into it — a `.branch` / `.switch` / `.stepSwitch` inside an
3796
+ * arm whose paths both end at the Merge — leave the join waiting for the path
3797
+ * that was not taken, and the run stays Running with no incident. That was
3798
+ * measured live on 09-01 (a Merge fed by a Decision's two ports). An older note
3799
+ * here said such flows "deploy and run"; they deploy, and they do not finish.
3800
+ * This function does NOT refuse that general shape yet — it predates the human
3801
+ * task rule below and is tracked as its own SDK issue. It does refuse it when a
3802
+ * human task's outcome edge is among the arm's alternative edges into the join
3803
+ * — a fan-out task at the arm's end, one empty `.stepSwitch` arm beside a body
3804
+ * arm, a task on one side of a `.branch` (HITL_FAN_OUT_INTO_JOIN) — and an
3805
+ * `action: 'End'` outcome inside an arm (HITL_END_OUTCOME_IN_PARALLEL).
3759
3806
  */
3760
3807
  function checkParallel(s, diags) {
3761
3808
  const at = (code, message, suggestion) => {
@@ -3798,6 +3845,14 @@ function checkParallel(s, diags) {
3798
3845
  + `terminate its own arm, or move the earlier step(s) before the \`.parallel(…)\` call.`, `.parallel('${s.name}', [(a) => a.terminate('${term.name}'), …])`);
3799
3846
  }
3800
3847
  });
3848
+ arms.forEach((arm, i) => {
3849
+ const steps = Array.isArray(arm) ? arm : [];
3850
+ const fanIn = hitlFanInto(steps);
3851
+ if (fanIn)
3852
+ diags.push(fanInDiag(s.name, i, steps, fanIn));
3853
+ for (const h of hitlEndHazards(steps))
3854
+ diags.push(endDiag(s.name, h));
3855
+ });
3801
3856
  // An arm that ends the flow reaches an End node instead of the Merge, so it
3802
3857
  // contributes NO incoming edge. Verified on the emitted graph: with two arms
3803
3858
  // and one returning, the Merge has a single incoming edge and is no longer a
@@ -3821,6 +3876,180 @@ function checkParallel(s, diags) {
3821
3876
  });
3822
3877
  }
3823
3878
  }
3879
+ /**
3880
+ * The edges a step list hands on, mirroring serialize's `emit` tail for tail.
3881
+ *
3882
+ * `.branch` / `.switch` / `.stepSwitch` hand on every arm's tails (an empty arm
3883
+ * hands on the port it started from), a fan-out human task one per continuing
3884
+ * outcome, a routed one its primary outcome; `.return` / `.terminate` / `.break`
3885
+ * and a `stepToRef('output', …)` hand on none, `.stepToList` leaves the path as
3886
+ * it was, and every other step hands on its own single edge.
3887
+ */
3888
+ function armTails(steps, incoming) {
3889
+ let tails = incoming;
3890
+ for (const st of steps) {
3891
+ if (st.kind === 'stepToList')
3892
+ continue;
3893
+ if (st.kind === 'stepToRef') {
3894
+ if (st.port === 'output')
3895
+ tails = [];
3896
+ continue;
3897
+ }
3898
+ if (st.kind === 'return' || st.kind === 'terminate' || st.kind === 'break')
3899
+ return [];
3900
+ if (st.kind === 'action') {
3901
+ tails = actionTails(st.name, st.spec, st.options?.version);
3902
+ }
3903
+ else if (st.kind === 'stepSwitch') {
3904
+ const spec = st.spec;
3905
+ const outcomes = spec.kind === 'hitl' && hitlDerivesOutcomeHandles(spec.inputs?.variant);
3906
+ tails = st.cases.flatMap((c) => armTails(c.body, [{ from: st.name, ...(outcomes ? { outcome: String(c.value) } : {}) }])
3907
+ .map((t) => (outcomes && !t.humanSwitch ? { ...t, humanSwitch: { step: st.name, outcome: String(c.value) } } : t)));
3908
+ }
3909
+ else if (st.kind === 'branch') {
3910
+ tails = [...armTails(st.then, [{ from: st.name }]), ...armTails(st.otherwise, [{ from: st.name }])];
3911
+ }
3912
+ else if (st.kind === 'switch') {
3913
+ tails = [
3914
+ ...st.cases.flatMap((c) => armTails(c.body, [{ from: st.name }])),
3915
+ ...(st.default !== undefined ? armTails(st.default, [{ from: st.name }]) : []),
3916
+ ];
3917
+ }
3918
+ else {
3919
+ tails = [{ from: st.name ?? st.kind }];
3920
+ }
3921
+ }
3922
+ return tails;
3923
+ }
3924
+ function actionTails(name, spec, version) {
3925
+ if (spec.kind !== 'hitl' || !hitlDerivesOutcomeHandles(spec.inputs?.variant))
3926
+ return [{ from: name }];
3927
+ const model = hitlExitModel(spec.inputs, version);
3928
+ if (model.shape === 'placeholder')
3929
+ return [{ from: name }];
3930
+ if (model.shape === 'per-outcome')
3931
+ return model.exits.slice(0, 1).map((e) => ({ from: name, outcome: e.value }));
3932
+ return model.exits.filter((e) => !e.endsProcess).map((e) => ({ from: name, outcome: e.value }));
3933
+ }
3934
+ /**
3935
+ * The tails of a `.parallel` arm when they would starve its join: two or more
3936
+ * alternative edges — only one fires — at least one of which is a human task's
3937
+ * outcome edge, or the end of a human-task `.stepSwitch` arm. The join waits
3938
+ * for a token on every incoming flow.
3939
+ *
3940
+ * However the task got there, it is the same graph: a fan-out task at the arm's
3941
+ * end (its outcomes are the alternatives), a `.stepSwitch` with an empty arm
3942
+ * beside a body arm, a complete `.stepSwitch` with nothing after it (one edge
3943
+ * per arm's last step), a task on one side of a `.branch`. serialize refuses the
3944
+ * same condition, edge for edge. Rejoining arms with NO human task in them are
3945
+ * the older, general case, and not refused here.
3946
+ */
3947
+ function hitlFanInto(steps) {
3948
+ const tails = armTails(steps, [{ from: '(fork)' }]);
3949
+ return tails.length >= 2 && tails.some((t) => t.outcome !== undefined || t.humanSwitch !== undefined) ? tails : undefined;
3950
+ }
3951
+ function fanInDiag(join, arm, steps, tails) {
3952
+ const tagged = tails.find((t) => t.outcome !== undefined || t.humanSwitch !== undefined);
3953
+ const task = tagged.outcome !== undefined ? tagged.from : tagged.humanSwitch.step;
3954
+ if (tails.every((t) => t.from === task && t.outcome !== undefined)) {
3955
+ return {
3956
+ level: 'error', code: 'HITL_FAN_OUT_INTO_JOIN', step: task,
3957
+ message: `Human task "${task}" ends its path inside parallel "${join}", so its outcomes `
3958
+ + `${tails.map((t) => t.outcome).join(', ')} each get their own edge into the join. Only one outcome ever `
3959
+ + `fires, and the join waits for a token on every incoming edge, so the run never gets past "${join}" — with `
3960
+ + `no incident, and \`flow validate\` answers Valid. Add a step after "${task}" inside the arm, so every `
3961
+ + `outcome continues to that step and only its one edge reaches the join (the decision is `
3962
+ + `out('${task}', 'Action')), or move the task out of the \`.parallel(…)\`.`,
3963
+ suggestion: `a.step('${task}', hitl(…)).step('record', …)`,
3964
+ };
3965
+ }
3966
+ const edges = tails.map((t) => (t.outcome !== undefined ? `outcome ${t.outcome} of "${t.from}"`
3967
+ : t.humanSwitch ? `"${t.from}" (on outcome ${t.humanSwitch.outcome} of "${t.humanSwitch.step}")` : `"${t.from}"`));
3968
+ const last = [...steps].reverse().find((x) => x.kind !== 'stepToList' && x.kind !== 'stepToRef');
3969
+ return {
3970
+ level: 'error', code: 'HITL_FAN_OUT_INTO_JOIN', step: task,
3971
+ message: `Parallel "${join}" arm ${arm + 1} ends on ${tails.length} alternative paths, and each gets its own edge `
3972
+ + `into the join: ${edges.join(', ')}. Only one of them runs, and the join waits for a token on every incoming `
3973
+ + `edge, so the run never gets past "${join}" — with no incident, and \`flow validate\` answers Valid. Add a `
3974
+ + `step at the end of the arm${last?.name ? `, after "${last.name}"` : ''}, so the paths meet on that step and `
3975
+ + `only its one edge reaches the join, or move human task "${task}" out of the \`.parallel(…)\`.`,
3976
+ suggestion: `….step('record', …) // last step of arm ${arm + 1}`,
3977
+ };
3978
+ }
3979
+ /**
3980
+ * Human tasks in one `.parallel` arm that continue every outcome while some of
3981
+ * them are `action: 'End'`. The End outcome's path stops at its End node, so the
3982
+ * join waits for the continuing path that did not run.
3983
+ *
3984
+ * Recurses into `.branch` / `.switch` / `.stepSwitch` bodies, which reach the
3985
+ * join too. Not into a loop body (the loop node is what reaches the join) or a
3986
+ * nested `.parallel` (its own join is checked on its own).
3987
+ */
3988
+ function hitlEndHazards(steps) {
3989
+ const found = [];
3990
+ for (const st of steps) {
3991
+ if (st.kind === 'action' && st.spec.kind === 'hitl') {
3992
+ const model = hitlExitModel(st.spec.inputs, st.options?.version);
3993
+ if (model.shape !== 'every-outcome')
3994
+ continue;
3995
+ const ending = model.exits.filter((e) => e.endsProcess).map((e) => e.value);
3996
+ if (ending.length > 0 && ending.length < model.exits.length)
3997
+ found.push({ step: st.name, outcomes: ending, via: 'action-end' });
3998
+ }
3999
+ else if (st.kind === 'branch') {
4000
+ found.push(...hitlEndHazards(st.then), ...hitlEndHazards(st.otherwise));
4001
+ }
4002
+ else if (st.kind === 'switch') {
4003
+ for (const c of st.cases)
4004
+ found.push(...hitlEndHazards(c.body));
4005
+ if (st.default)
4006
+ found.push(...hitlEndHazards(st.default));
4007
+ }
4008
+ else if (st.kind === 'stepSwitch') {
4009
+ // An outcome with no arm gets an End (serialize's `endExit`), and so does
4010
+ // an arm that ends in `.return()`. Beside an arm that continues, that End
4011
+ // is where this arm's token stops on that outcome.
4012
+ const spec = st.spec;
4013
+ const exits = spec.kind === 'hitl' && hitlDerivesOutcomeHandles(spec.inputs?.variant)
4014
+ ? declaredExits(st.spec, st.options?.version) : undefined;
4015
+ if (exits && hasExits(exits)) {
4016
+ const stopped = exits.filter((e) => {
4017
+ const c = st.cases.find((x) => x.value === e.value);
4018
+ return !c || (armTails(c.body, [{ from: st.name }]).length === 0 && stopsAtEnd(c.body));
4019
+ }).map((e) => e.value);
4020
+ if (stopped.length > 0 && armTails([st], [{ from: '(fork)' }]).length > 0) {
4021
+ found.push({ step: st.name, outcomes: stopped, via: 'step-switch' });
4022
+ }
4023
+ }
4024
+ for (const c of st.cases)
4025
+ found.push(...hitlEndHazards(c.body));
4026
+ }
4027
+ }
4028
+ return found;
4029
+ }
4030
+ function endDiag(join, h) {
4031
+ const list = h.outcomes.join(', ');
4032
+ if (h.via === 'step-switch') {
4033
+ return {
4034
+ level: 'error', code: 'HITL_END_OUTCOME_IN_PARALLEL', step: h.step,
4035
+ message: `Human task "${h.step}" is a .stepSwitch inside parallel "${join}", and ${h.outcomes.length === 1
4036
+ ? `outcome ${list} has` : `outcomes ${list} have`} no arm, or an arm that ends in .return(). That path `
4037
+ + `stops at an End node, while the join waits for a token from this arm on the path that continues, so the `
4038
+ + `run never gets past "${join}" — with no incident, and \`flow validate\` answers Valid. Give every outcome `
4039
+ + `an arm that continues (and move a .return() after the \`.parallel(…)\`), or move the task out of the `
4040
+ + `\`.parallel(…)\`.`,
4041
+ suggestion: `{ value: '${h.outcomes[0]}', body: (b) => b.step(…) }`,
4042
+ };
4043
+ }
4044
+ return {
4045
+ level: 'error', code: 'HITL_END_OUTCOME_IN_PARALLEL', step: h.step,
4046
+ message: `Human task "${h.step}" is inside parallel "${join}", and ${h.outcomes.length === 1
4047
+ ? `outcome ${list} is` : `outcomes ${list} are`} action: 'End'. `
4048
+ + `That path stops at its own End node, while the join waits for a token from this arm on its `
4049
+ + `continuing path, so the run never gets past "${join}". Drop action: 'End' there and act on `
4050
+ + `out('${h.step}', 'Action') after the join, or move the task out of the \`.parallel(…)\`.`,
4051
+ };
4052
+ }
3824
4053
  /**
3825
4054
  * Diagnose a `transform` step.
3826
4055
  *
@@ -4059,34 +4288,46 @@ function outcomeSlug(name, index) {
4059
4288
  .replace(/^-+|-+$/g, '');
4060
4289
  return slug || `outcome-${index + 1}`;
4061
4290
  }
4291
+ /**
4292
+ * Why a base or quick-form task continues every outcome to the next step, as a
4293
+ * clause for a diagnostic. `pinned` is the step's `{ version }`.
4294
+ */
4295
+ function fanOutWhy(why, pinned) {
4296
+ switch (why) {
4297
+ case 'outcomePorts: false': return 'outcomePorts: false says so';
4298
+ case 'quick-form': return 'a quick-form task does not unless it sets outcomePorts: true';
4299
+ case 'pinned': return `{ version: '${pinned}' } pins it without outcomePorts: true`;
4300
+ case 'one outcome': return 'it has a single outcome';
4301
+ case 'every outcome ends': return "every outcome is action: 'End'";
4302
+ }
4303
+ }
4062
4304
  /**
4063
4305
  * HITL_OUTCOMES_UNREACHABLE — a human task offers the reviewer a CHOICE that
4064
4306
  * nothing downstream can act on.
4065
4307
  *
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`
4308
+ * A task that does not route per outcome sends every outcome to the same place:
4309
+ * on the base node and quick-form, one `outcome-<id>` edge per outcome, all to
4310
+ * the same next step; on an app-backed variant, its one `completed` exit. The
4311
+ * outcomes reach the reviewer and `inputs.schema.outcomes`, and then every
4312
+ * button the reviewer can press leads to the same step. Two outcomes, one path —
4313
+ * an approval whose approve and reject arms are indistinguishable is a modelling
4314
+ * error, not a style choice, yet it compiles and `flow validate` answers `Valid`
4072
4315
  * (flow-builder-sdk#735).
4073
4316
  *
4074
4317
  * On a plain base task that is no longer reachable: more than one outcome now
4075
4318
  * INFERS per-outcome routing (`hitlRoutesPerOutcome`, #739), because a warning
4076
4319
  * alone did not change what agents emitted — #739 measured the diagnostic
4077
4320
  * 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`.
4321
+ * for this rule is the cases where not routing is CHOSEN and the choice may
4322
+ * still be a mistake: quick-form's default, a pinned `{ version }`, an explicit
4323
+ * `outcomePorts: false`, and the app-backed variants.
4082
4324
  *
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.
4325
+ * In all of them the shape is correct when something reads the chosen outcome:
4326
+ * `out('<step>', 'Action')` feeding a `.switch()` routes the decision as data.
4327
+ * So the rule is not "outcomes without routes", which would refuse that shape;
4328
+ * it is "outcomes that no route and no expression distinguishes". That is why it
4329
+ * runs here, after `walk`, rather than inside `checkHitl`: the deciding evidence
4330
+ * is the rest of the flow.
4090
4331
  *
4091
4332
  * A read counts when it can SEE the outcome — `.output.Action`, the whole
4092
4333
  * answer object (`out('<step>')` / `$vars.<step>.output`, which carries
@@ -4116,35 +4357,46 @@ function reportUnreachableHitlOutcomes(choices, exprs, diags) {
4116
4357
  for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)\s*\[/g))
4117
4358
  readsOutcome.add(m[1]);
4118
4359
  }
4119
- for (const [step, { names, variant, pinned, portsOff }] of choices) {
4360
+ for (const [step, { names, ends, variant, pinned, portsOff }] of choices) {
4120
4361
  if (readsOutcome.has(step))
4121
4362
  continue;
4122
4363
  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`;
4364
+ // The base node and quick-form draw a handle per outcome, so both can route
4365
+ // it — `.stepSwitch`, or `outcomePorts: true` (a pin keeps its version). An
4366
+ // app-backed variant cannot: the Action read is its only remedy.
4367
+ const canRoute = hitlDerivesOutcomeHandles(variant);
4368
+ // On those two, an `action: 'End'` outcome gets its own End node, so the
4369
+ // graph already tells it apart. Only the outcomes that CONTINUE share a step.
4370
+ const continuing = canRoute ? names.filter((n) => !ends.includes(n)) : names;
4371
+ if (continuing.length < 2)
4372
+ continue;
4373
+ const endNote = canRoute && ends.length > 0
4374
+ ? ` (${ends.join(', ')} ${ends.length === 1 ? 'is' : 'are'} action: 'End' and end${ends.length === 1 ? 's' : ''} the run instead)`
4375
+ : '';
4376
+ const kept = !canRoute
4377
+ ? `The "${variant}" variant reads its outcomes from its app and leaves on its single 'completed' exit`
4378
+ : portsOff
4379
+ ? `outcomePorts: false continues every outcome to the next step`
4380
+ : variant === 'quick-form'
4381
+ ? `A quick-form task continues every outcome to the next step unless it routes them`
4382
+ : `{ version: '${pinned}' } without outcomePorts: true continues every outcome to the next step`;
4135
4383
  diags.push({
4136
4384
  level: 'warning', code: 'HITL_OUTCOMES_UNREACHABLE', step,
4137
4385
  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(', ')}).`
4386
+ + `downstream tells them apart: ${canRoute
4387
+ ? `${ends.length > 0 ? `${continuing.join(', ')} continue` : 'every outcome continues'} to the same step${endNote}`
4388
+ : "the node leaves on its single 'completed' exit"}, and no expression reads `
4389
+ + `out('${step}', 'Action') either. The outcomes reach the reviewer and the artifact; the decision `
4390
+ + `does not reach the flow. ${kept}, so switch on out('${step}', 'Action') to route the decision as data`
4391
+ + (canRoute
4392
+ ? `, or give each outcome its own route (${ids.map((i) => `'outcome-${i}'`).join(', ')}) with `
4393
+ + `.stepSwitch${portsOff ? ' or by dropping outcomePorts: false' : ' or outcomePorts: true'}.`
4144
4394
  : '.'),
4145
- suggestion: canTakePorts
4395
+ suggestion: portsOff
4146
4396
  ? 'drop outcomePorts: false'
4147
- : `.switch('route', out('${step}', 'Action'), …)`,
4397
+ : canRoute
4398
+ ? `.stepSwitch('${step}', …), or outcomePorts: true`
4399
+ : `.switch('route', out('${step}', 'Action'), …)`,
4148
4400
  });
4149
4401
  }
4150
4402
  }
@@ -4174,12 +4426,19 @@ function checkHitl(step, inputs, diags) {
4174
4426
  }
4175
4427
  const isApp = variant === 'action-app';
4176
4428
  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.`);
4429
+ // The error output is the BASE node's 1.2 definition, which no variant has.
4430
+ // Per-outcome exits are not a version question: quick-form draws one handle
4431
+ // per outcome like the base node, so it may ask for them; the two app-backed
4432
+ // variants read their outcomes from the app and leave on `completed`.
4433
+ if (variant !== undefined
4434
+ && (inputs?.exposeError === true || (inputs?.outcomePorts === true && !hitlDerivesOutcomeHandles(variant)))) {
4435
+ at('HITL_OUTCOME_PORTS_VARIANT', inputs?.exposeError === true
4436
+ ? `Human task "${step}" asks for exposeError, but its variant is "${variant}" — the error output `
4437
+ + `exists only on the base node type's 1.2 definition.`
4438
+ : `Human task "${step}" asks for outcomePorts, but its variant is "${variant}", which reads its `
4439
+ + `outcomes from its app and leaves on its single 'completed' exit.`, inputs?.exposeError === true
4440
+ ? 'Omit `variant`, or drop exposeError.'
4441
+ : `Route on out('${step}', 'Action') downstream.`);
4183
4442
  return;
4184
4443
  }
4185
4444
  if (inputs?.document !== undefined && !isDocval) {