@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/bpmn/registry-types.json +1 -1
- package/dist/check.js +357 -114
- package/dist/core/actions.d.ts +20 -20
- package/dist/core/hitl-routing.d.ts +129 -34
- package/dist/core/hitl-routing.js +97 -13
- package/dist/core/step-ports.d.ts +8 -0
- package/dist/core/step-ports.js +37 -30
- package/dist/decompile.js +85 -14
- package/dist/serialize.js +192 -58
- package/package.json +2 -2
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
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
|
228
|
-
// that
|
|
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
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// `
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
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
|
|
246
|
-
if (
|
|
247
|
-
const
|
|
248
|
-
const
|
|
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}".
|
|
255
|
-
+
|
|
256
|
-
+ `
|
|
257
|
-
+
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
*
|
|
3751
|
-
*
|
|
3752
|
-
* exclusive
|
|
3753
|
-
*
|
|
3754
|
-
*
|
|
3755
|
-
*
|
|
3756
|
-
*
|
|
3757
|
-
*
|
|
3758
|
-
* does
|
|
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
|
-
*
|
|
4067
|
-
*
|
|
4068
|
-
*
|
|
4069
|
-
*
|
|
4070
|
-
*
|
|
4071
|
-
*
|
|
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
|
|
4079
|
-
* still be a mistake:
|
|
4080
|
-
*
|
|
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
|
|
4084
|
-
* `out('<step>', 'Action')` feeding a `.switch()` routes the decision as data
|
|
4085
|
-
*
|
|
4086
|
-
*
|
|
4087
|
-
*
|
|
4088
|
-
*
|
|
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
|
-
//
|
|
4124
|
-
// `outcomePorts: true`
|
|
4125
|
-
//
|
|
4126
|
-
|
|
4127
|
-
//
|
|
4128
|
-
//
|
|
4129
|
-
const
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
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:
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
+ `out('${step}', 'Action')
|
|
4142
|
-
+ (
|
|
4143
|
-
|
|
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:
|
|
4379
|
+
suggestion: portsOff
|
|
4146
4380
|
? 'drop outcomePorts: false'
|
|
4147
|
-
:
|
|
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
|
-
//
|
|
4178
|
-
//
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
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) {
|