@uipath/maestro-builder-sdk 6.59.1 → 6.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +10 -12
  2. package/dist/api-index.md +5 -4
  3. package/dist/check-cli.js +8 -7
  4. package/dist/check.d.ts +11 -0
  5. package/dist/check.js +192 -783
  6. package/dist/cli-library.d.ts +2 -1
  7. package/dist/cli-library.js +7 -2
  8. package/dist/cli-result.d.ts +14 -0
  9. package/dist/cli-result.js +18 -1
  10. package/dist/compile-cli.js +2 -2
  11. package/dist/core/compile-refusal.d.ts +7 -1
  12. package/dist/core/compile-refusal.js +9 -1
  13. package/dist/core/connector-checks.d.ts +8 -30
  14. package/dist/core/connector-checks.js +11 -60
  15. package/dist/core/connector-inputs.js +9 -1
  16. package/dist/core/event-checks.d.ts +15 -2
  17. package/dist/core/event-checks.js +28 -13
  18. package/dist/core/hitl-routing.d.ts +10 -1
  19. package/dist/core/hitl-routing.js +20 -5
  20. package/dist/core/lookups.d.ts +13 -0
  21. package/dist/core/lookups.js +26 -0
  22. package/dist/core/refusals.d.ts +2 -0
  23. package/dist/core/refusals.js +5 -1
  24. package/dist/core/script-function.d.ts +12 -0
  25. package/dist/core/script-function.js +119 -1
  26. package/dist/flow-node-type.d.ts +35 -21
  27. package/dist/flow-parallel-hitl.d.ts +105 -0
  28. package/dist/flow-parallel-hitl.js +214 -0
  29. package/dist/flow-sdk.d.ts +4 -4
  30. package/dist/flow-sdk.js +2 -2
  31. package/dist/flow-terminate-names.d.ts +2 -2
  32. package/dist/flow-terminate-names.js +2 -2
  33. package/dist/merge-compile.js +2 -1
  34. package/dist/nodes/agent/flow/check.js +13 -88
  35. package/dist/nodes/agent/flow/inline-agent.js +41 -14
  36. package/dist/nodes/connector/flow/node-type.js +45 -18
  37. package/dist/nodes/document/flow/ixp.d.ts +1 -1
  38. package/dist/nodes/document/flow/ixp.js +22 -26
  39. package/dist/nodes/hitl/flow/check.js +4 -11
  40. package/dist/nodes/hitl/flow/node-type.js +16 -7
  41. package/dist/nodes/http/flow/check.js +5 -31
  42. package/dist/nodes/http/flow/node-type.js +7 -4
  43. package/dist/nodes/queue/flow/check.d.ts +4 -3
  44. package/dist/nodes/queue/flow/check.js +7 -6
  45. package/dist/nodes/queue/flow/node-type.js +1 -1
  46. package/dist/nodes/script/flow/runtime.d.ts +17 -5
  47. package/dist/nodes/script/flow/runtime.js +40 -5
  48. package/dist/nodes/transform/flow/check.d.ts +2 -0
  49. package/dist/nodes/transform/flow/check.js +3 -6
  50. package/dist/nodes/transform/flow/node-type.js +6 -6
  51. package/dist/raw-node.js +8 -8
  52. package/dist/script-globals.d.ts +35 -0
  53. package/dist/script-globals.js +1 -0
  54. package/dist/serialize.d.ts +38 -3
  55. package/dist/serialize.js +757 -322
  56. package/package.json +6 -2
package/dist/check.js CHANGED
@@ -2,61 +2,39 @@
2
2
  * check — static validation of a BuiltFlow before serialization.
3
3
  *
4
4
  * Produces structured, actionable diagnostics (the kind an agent can act on):
5
- * duplicate step names, references to undeclared variables/steps, empty branch
6
- * arms, and missing terminals.
5
+ * references to undeclared variables/steps, empty branch arms, missing
6
+ * terminals, and every refusal `compile` would raise (duplicate step names, an
7
+ * input a connector does not declare, …), which it gets by running the
8
+ * serializer in collect mode (`serializerRefusals`), so each of those rules
9
+ * lives in the serializer alone.
7
10
  */
8
11
  import { Expr, SCHEDULE_PRESETS, parallelFork } from './flow-sdk.js';
9
12
  import { FlowNode } from './core/node-classes.js';
10
- import { planContributionBindings } from './core/contribution-bindings.js';
11
- import { definitionHasErrorPort, errorPortRefusal } from './core/error-port.js';
12
- import { bundledActionNodeType, bundledContainerDefinition, bundledDefinition, resolveContributionDefinition } from './serialize.js';
13
+ import { definitionHasErrorPort } from './core/error-port.js';
14
+ import { bundledDefinition, resolveContributionDefinition, serialize } from './serialize.js';
13
15
  import { checkEventResolution, checkEventSubscription, eventParamsFor, } from './core/event-checks.js';
14
16
  import { checkCookedEscapes } from './flow-expr-check.js';
15
- import { FLOW_EXPR_DIALECT, checkBindingLabels, checkBindingPresence, checkConnectorContract as coreCheckConnectorContract, checkConnectorLookups as coreCheckConnectorLookups, checkConnectorSchema as coreCheckConnectorSchema, collectConnectionUses, connectionConnectorConflicts, } from './core/connector-checks.js';
16
- import { commentText } from './core/comment-text.js';
17
+ import { FLOW_EXPR_DIALECT, checkBindingLabels, checkConnectorLookups as coreCheckConnectorLookups, checkConnectorSchema as coreCheckConnectorSchema, } from './core/connector-checks.js';
17
18
  import { nearestName } from './core/edit-distance.js';
18
19
  import { hitlDerivesOutcomeHandles, hitlExitModel, hitlRoutesPerOutcome, outcomeIdOf } from './core/hitl-routing.js';
19
20
  import { hasExits } from './core/step-ports.js';
20
- import { stopsAtEnd } from './flow-step-tree.js';
21
+ import { actionTails, armReachesJoin } from './flow-parallel-hitl.js';
21
22
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
22
23
  import { compareSchemaVersions, schemaVersionRefusal } from './schema-version.js';
23
- import { planFlowGlobals, planSharedInputs, sharedInputRoots } from './flow-variables.js';
24
- import { planOutputBacking } from './flow-output-backing.js';
24
+ import { planSharedInputs, sharedInputRoots } from './flow-variables.js';
25
25
  import { VARS_READ_RE } from './core/vars-read.js';
26
- import { terminateStepClashMessage, terminateTwiceMessage } from './flow-terminate-names.js';
27
26
  import { endContentKey } from './flow-end-content.js';
28
27
  import { FLOW_NODE_TYPES } from './flow-node-registry.js';
29
28
  import { stepExits } from './flow-step-exits.js';
30
29
  import { stepDeclaredVariables } from './flow-step-variables.js';
31
30
  import { plainHttpOnErrorMessage } from './nodes/http/flow/family.js';
32
31
  import { resolveVersion, resolveVersionCarried } from './core/nodes/versioned.js';
33
- import { declaredErrorPortRefusal } from './core/error-port.js';
34
- import { bodyUsesBreak, loopParallelWarning, loopVersionRefusal, resolveLoopDefinition, loopVersion } from './loop-version.js';
35
- const newResourceIdLedger = () => ({ explicit: new Map(), taken: new Map(), agentSources: new Map() });
36
- /** INLINE_AGENT_RESOURCE_NODE_ID for each explicit `nodeId` another node of the file also gets. */
37
- function resourceNodeIdClashes(ledger) {
38
- const out = [];
39
- for (const [id, { step, what }] of ledger.explicit) {
40
- const other = ledger.taken.get(id);
41
- if (other === undefined)
42
- continue;
43
- out.push({
44
- level: 'error', code: 'INLINE_AGENT_RESOURCE_NODE_ID', step,
45
- message: `Inline agent step "${step}" gives its ${what} the \`nodeId\` "${id}", which is already `
46
- + `${other}. Node ids are unique in a flow, so compile cannot write both. Give the resource another id, `
47
- + 'or omit `nodeId` to derive one.',
48
- });
49
- }
50
- return out;
51
- }
32
+ import { loopParallelWarning, resolveLoopDefinition, loopVersion } from './loop-version.js';
52
33
  export function check(built, opts = {}) {
53
- let diags = [];
54
- const ledger = newResourceIdLedger();
55
- const uses = collectConnectionUses(() => { diags = checkFlow(built, [], opts, ledger); });
56
- diags.push(...resourceNodeIdClashes(ledger));
57
- for (const { id, message } of connectionConnectorConflicts(uses, opts)) {
58
- diags.push({ level: 'error', code: 'CONNECTION_CONNECTOR_CONFLICT', step: id, message });
59
- }
34
+ // A connection two connectors use, and an agent resource's `nodeId` another
35
+ // node of the file has, are the serializer's CONNECTION_CONNECTOR_CONFLICT /
36
+ // INLINE_AGENT_RESOURCE_NODE_ID: it claims ids and resolves connections file-wide.
37
+ const diags = checkFlow(built, [], opts);
60
38
  // Once, from the top: the walk reaches child flows itself, so running it per
61
39
  // `checkFlow` would report a child's escape twice.
62
40
  for (const d of checkCookedEscapes(built)) {
@@ -66,37 +44,65 @@ export function check(built, opts = {}) {
66
44
  ...(d.suggestion ? { suggestion: d.suggestion } : {}),
67
45
  });
68
46
  }
47
+ if (opts.serializer !== false)
48
+ diags.push(...serializerRefusals(built, { library: opts.library, bindings: opts.bindings }, diags));
69
49
  return diags;
70
50
  }
51
+ /** The error-level diagnostics a custom node's own `diagnostics()` reported (see `serializerRefusals`). */
52
+ const OWN_NODE_ERRORS = new WeakSet();
71
53
  /**
72
- * A step's node id is its name with every character outside `[A-Za-z0-9_]`
73
- * made `_`, and `compile` never renames one to make it unique (#1249). So two
74
- * different names that give one id (`a b` and `a_b`), or a step of the flow
75
- * itself named like its trigger, are an error here, as the same name twice is
76
- * DUP_STEP. `serialize` refuses both too, for the paths that skip `check`.
77
- * A subflow's own start is the SDK's to name, so `trigger` is the flow's only.
54
+ * Every refusal `compile` would raise for `built`: the serializer, run in
55
+ * collect mode (docs/CHECK_COMPILE_CONTRACT_DESIGN.md §3.5), so each rule it
56
+ * enforces is reported from its one implementation. A refusal inside a subflow
57
+ * is worded and placed as `check` words a child's findings: `In subflow "call": …`
58
+ * on step `call › x`.
59
+ *
60
+ * A throw that is no refusal (a malformed spec the serializer has no refusal
61
+ * for) comes back as COMPILE_FAILED, on its step, which is what `compile` says;
62
+ * on a step `check` already refuses it is that refusal's consequence and is not
63
+ * repeated. An `internal SDK error:` is passed on, unless `check` or the
64
+ * serializer already found an error: `compile` runs `check` first and stops at
65
+ * its errors, so it never serializes such a flow, and the author has those
66
+ * errors to fix first.
78
67
  */
79
- function checkStepIds(stepNames, trigger, diags) {
80
- const byId = new Map();
81
- for (const name of stepNames) {
82
- const id = name.replace(/[^A-Za-z0-9_]/g, '_') || 'node';
83
- const first = byId.get(id);
84
- if (first !== undefined) {
85
- diags.push({
86
- level: 'error', code: 'STEP_ID_COLLISION', step: name,
87
- message: `Steps "${first}" and "${name}" would both get node id "${id}". Give them names that differ in `
88
- + 'letters, digits or "_".',
89
- });
90
- }
91
- else if (id === trigger) {
92
- diags.push({
93
- level: 'error', code: 'STEP_ID_COLLISION', step: name,
94
- message: `Step "${name}" has the trigger's node id "${id}". Rename the step.`,
95
- });
96
- }
97
- if (first === undefined)
98
- byId.set(id, name);
68
+ function serializerRefusals(built, compile, found) {
69
+ const collected = [];
70
+ try {
71
+ serialize(built, { ...compile, collect: (r) => collected.push(r) });
72
+ }
73
+ catch (error) {
74
+ // LIBRARY_REQUIRED is dropped below, so it does not count as something reported.
75
+ if (!found.some((d) => d.level === 'error') && !collected.some((r) => r.code !== 'LIBRARY_REQUIRED'))
76
+ throw error;
77
+ }
78
+ const out = [];
79
+ const seen = new Set();
80
+ for (const r of collected) {
81
+ // "Compile needs a library" is about compile's inputs, not the flow: a check
82
+ // run without one fails open, as its library rules do.
83
+ if (r.code === 'LIBRARY_REQUIRED')
84
+ continue;
85
+ const subflows = r.subflows ?? [];
86
+ const step = r.step !== undefined ? [...subflows, r.step].join(' › ') : undefined;
87
+ // A custom node whose own diagnostics() reported an error has said why it
88
+ // cannot be lowered; its contribute() throwing too is not a second finding.
89
+ if (r.code === 'CONTRIBUTION_INVALID' && found.some((d) => d.step === step && OWN_NODE_ERRORS.has(d)))
90
+ continue;
91
+ if (r.code === 'COMPILE_FAILED' && found.some((d) => d.level === 'error' && d.step === step))
92
+ continue;
93
+ const message = subflows.reduceRight((m, caller) => `In subflow "${caller}": ${m}`, r.message);
94
+ // One finding per step path: the same refusal twice there is one rule broken once.
95
+ const key = `${r.code}\n${step ?? ''}\n${message}`;
96
+ if (seen.has(key))
97
+ continue;
98
+ seen.add(key);
99
+ out.push({
100
+ level: 'error', code: r.code, message,
101
+ ...(step !== undefined ? { step } : {}),
102
+ ...(r.suggestion !== undefined ? { suggestion: r.suggestion } : {}),
103
+ });
99
104
  }
105
+ return out;
100
106
  }
101
107
  /**
102
108
  * `ancestors` is the chain of child flows currently being checked, innermost
@@ -104,7 +110,7 @@ function checkStepIds(stepNames, trigger, diags) {
104
110
  * to notice a flow that (transitively) calls itself before the serializer
105
111
  * recurses forever.
106
112
  */
107
- function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger()) {
113
+ function checkFlow(built, ancestors, opts = {}) {
108
114
  const diags = [];
109
115
  if (!built.id)
110
116
  diags.push({ level: 'error', code: 'NO_ID', message: 'flow() requires an id.' });
@@ -115,11 +121,9 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
115
121
  if (maybeCustomTrigger !== undefined && !('kind' in maybeCustomTrigger) && maybeCustomTrigger instanceof FlowNode) {
116
122
  // A custom FlowTrigger subclass reports through its own diagnostics() hook;
117
123
  // the built-in trigger rules key on `kind` and don't apply.
118
- const own = maybeCustomTrigger.diagnostics();
119
- for (const d of own) {
124
+ for (const d of maybeCustomTrigger.diagnostics()) {
120
125
  diags.push({ level: d.severity, code: d.code, message: d.message, step: 'trigger' });
121
126
  }
122
- checkContribution(maybeCustomTrigger, built.triggerOptions?.version, 'trigger', own.some((d) => d.severity === 'error'), diags);
123
127
  }
124
128
  else if (built.trigger !== undefined)
125
129
  checkTrigger(built.trigger, diags, opts);
@@ -315,27 +319,11 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
315
319
  ? { kind: 'action', name: s0.name, spec: s0.spec, ...(s0.options ? { options: s0.options } : {}) }
316
320
  : s0;
317
321
  // `return` carries a synthetic `name` that names no node and is not
318
- // referenceable — registering it would report DUP_STEP the second time one
319
- // appears. The port-edge kinds carry no name at all.
322
+ // referenceable, so it is not registered. The port-edge kinds carry no
323
+ // name at all.
320
324
  if ('name' in s && s.kind !== 'return') {
321
- if (stepNames.has(s.name)) {
322
- // Two `.terminate('<name>')` calls are two Terminate nodes: the second
323
- // would be renamed `<name>2`, so paths that all stop at ONE Terminate
324
- // come back with an extra node (#1239). Same code as any step clash (a
325
- // Terminate's name is a step name), with the remedy spelled out.
326
- // A step and a Terminate sharing a name (either order) get the same
327
- // words serialize throws for them: rename one.
328
- const twoTerminates = s.kind === 'terminate' && terminateNames.has(s.name);
329
- const oneTerminate = (s.kind === 'terminate') !== terminateNames.has(s.name);
330
- diags.push({
331
- level: 'error', code: 'DUP_STEP', step: s.name,
332
- message: twoTerminates
333
- ? terminateTwiceMessage(s.name)
334
- : oneTerminate
335
- ? terminateStepClashMessage(s.name)
336
- : `Duplicate step name "${s.name}".`,
337
- });
338
- }
325
+ // A name already taken in this scope is the serializer's DUP_STEP, with
326
+ // the remedy for a step and a Terminate, or two Terminates (#1239).
339
327
  stepNames.add(s.name);
340
328
  declared.add(s.name); // a step's output is referenceable by name
341
329
  // …and so is every variable the step declares by running: a quick
@@ -345,7 +333,7 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
345
333
  declared.add(v);
346
334
  }
347
335
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port === 'error')
348
- checkOnError(s, prevPortSource, diags, prevPortSource && checkedSteps.get(prevPortSource.name), opts, carriedLookup([built, ...ancestors]));
336
+ checkOnError(s, prevPortSource, diags, prevPortSource && checkedSteps.get(prevPortSource.name), opts);
349
337
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port.startsWith('branch-')) {
350
338
  const want = s.port.slice('branch-'.length);
351
339
  const prevSpec = prevAction?.spec;
@@ -361,38 +349,9 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
361
349
  });
362
350
  }
363
351
  }
364
- // The mirror of HITL_OUTCOME_PORTS_OFF below: `completed` on a base or
365
- // quick-form human task that has outcomes.
366
- //
367
- // The product draws one `outcome-<id>` handle per outcome on those two node
368
- // types, at every definition version, and `completed` only when there are
369
- // none (flow-workbench `build-handle-customization.ts`). An edge on it is
370
- // stale: Studio Web and the converter remap it to the PRIMARY outcome
371
- // (#3512), so every other outcome loses its path — and `flow validate`
372
- // accepts it without `handleCustomization`, so nothing later catches it.
373
- // `serialize` refuses to emit one; this says why, in the author's terms.
374
- if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port === 'completed') {
375
- const prevSpec = prevAction?.spec;
376
- const isHitl = !!prevSpec && 'kind' in prevSpec && prevSpec.kind === 'hitl';
377
- const model = isHitl ? hitlExitModel(prevSpec.inputs, prevAction?.options?.version) : undefined;
378
- if (model && model.shape !== 'placeholder') {
379
- const ports = model.exits.map((e) => `"${e.port}"`).join(', ');
380
- const routed = model.shape === 'per-outcome';
381
- diags.push({
382
- level: 'error', code: 'HITL_COMPLETED_PORT_GONE', step: prevAction?.name,
383
- message: `Port "completed" does not exist on human task "${prevAction?.name}". It has outcomes, so `
384
- + `the product draws one handle per outcome (${ports}) and no 'completed' — an edge there is `
385
- + `remapped to the primary outcome and the others lose their path. `
386
- + (routed
387
- ? `The FIRST outcome ("${model.exits[0]?.port}") is what continues the main path.`
388
- : `.step() after this task already continues every outcome to the next step.`),
389
- suggestion: routed
390
- ? `route the outcomes (.stepToList('${model.exits[1]?.port ?? model.exits[0]?.port}', …)), or continue `
391
- + `every outcome to the next step with outcomePorts: false`
392
- : `drop the 'completed' route — .step() continues every outcome — or route each outcome with .stepSwitch`,
393
- });
394
- }
395
- }
352
+ // `completed` on a base or quick-form human task that has outcomes is the
353
+ // serializer's HITL_COMPLETED_PORT_GONE: the product draws one
354
+ // `outcome-<id>` handle per outcome and no `completed` (#3512).
396
355
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port.startsWith('outcome-')) {
397
356
  const want = s.port.slice('outcome-'.length);
398
357
  const prevSpec = prevAction?.spec;
@@ -407,41 +366,12 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
407
366
  else {
408
367
  const model = hitlExitModel(prevSpec.inputs, prevAction?.options?.version);
409
368
  const ids = (prevSpec.inputs.outcomes ?? []).map((o, i) => outcomeIdOf(o, i));
410
- if (model.shape === 'placeholder') {
411
- diags.push({
412
- level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
413
- message: `Port "${s.port}" routes a per-outcome exit, but human task "${prevAction?.name}" `
414
- + `has none — ${ids.length === 0 ? 'it declares no outcomes'
415
- : model.legacyCompleted ? 'it sets legacyCompleted: true'
416
- : `the '${prevSpec.inputs?.variant}' variant reads its outcomes from its app`}, so its single `
417
- + `exit is 'completed'.`,
418
- suggestion: model.legacyCompleted
419
- ? 'drop `legacyCompleted` (and set outcomePorts: true to route each outcome)'
420
- : `route on out('${prevAction?.name}', 'Action') downstream`,
421
- });
422
- }
423
- else if (!ids.includes(want)) {
424
- diags.push({
425
- level: 'error', code: 'HITL_OUTCOME_PORT_UNKNOWN', step: prevAction?.name,
426
- message: `Port "${s.port}" routes the outcome id ${JSON.stringify(want)}, but human task `
427
- + `"${prevAction?.name}" declares ${ids.map((i) => `outcome-${i}`).join(', ')}. Ids are the `
428
- + `outcome names slugified (lowercase, non-alphanumerics to '-').`,
429
- });
430
- }
431
- else if (model.shape === 'every-outcome') {
432
- // The port is real, but it is ALREADY wired: a task that does not
433
- // route per outcome continues every outcome to the next step, so a
434
- // second route would put two edges on one port (#741).
435
- diags.push({
436
- level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
437
- message: `Port "${s.port}" is already wired: human task "${prevAction?.name}" does not route per `
438
- + `outcome — ${fanOutWhy(model.why, prevAction?.options?.version)} — so .step() continues every `
439
- + `outcome to the next step, "${s.port}" included. A second route would put two edges on one port.`,
440
- suggestion: `outcomePorts: true (then the first outcome continues and the others are routed), `
441
- + `or route every outcome with .stepSwitch`,
442
- });
443
- }
444
- else if (prevAction) {
369
+ // A task with no per-outcome exits, an id the task does not declare,
370
+ // and a port a task that does not route per outcome already continues
371
+ // on are the serializer's HITL_OUTCOME_PORTS_NONE /
372
+ // HITL_OUTCOME_PORT_UNKNOWN / HITL_OUTCOME_PORTS_OFF (#741). A routed
373
+ // port is recorded for the unrouted-outcome rule.
374
+ if (model.shape === 'per-outcome' && ids.includes(want) && prevAction) {
445
375
  let routed = hitlOutcomeRouted.get(prevAction.name);
446
376
  if (!routed)
447
377
  hitlOutcomeRouted.set(prevAction.name, routed = new Set());
@@ -454,7 +384,7 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
454
384
  prevPortSource = s;
455
385
  const registered = registeredKind(s.spec);
456
386
  if (registered) {
457
- const { placed, checked } = checkRegisteredStep(registered, s, exprs, errorSource, opts, diags, typeOf, triggerId, ledger, carriedLookup([built, ...ancestors]));
387
+ const { placed, checked } = checkRegisteredStep(registered, s, exprs, errorSource, opts, diags, typeOf, triggerId, carriedLookup([built, ...ancestors]));
458
388
  checkedSteps.set(s.name, checked);
459
389
  if (placed)
460
390
  registeredSteps.set(s.name, checked);
@@ -468,11 +398,12 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
468
398
  // don't apply, and it reports through its own diagnostics() hook.
469
399
  const maybeCustom = s.spec;
470
400
  if (!('kind' in maybeCustom) && maybeCustom instanceof FlowNode) {
471
- const own = maybeCustom.diagnostics();
472
- for (const d of own) {
473
- diags.push({ level: d.severity, code: d.code, message: d.message, step: s.name });
401
+ for (const d of maybeCustom.diagnostics()) {
402
+ const diag = { level: d.severity, code: d.code, message: d.message, step: s.name };
403
+ if (d.severity === 'error')
404
+ OWN_NODE_ERRORS.add(diag);
405
+ diags.push(diag);
474
406
  }
475
- checkContribution(maybeCustom, s.options?.version, s.name, own.some((d) => d.severity === 'error'), diags);
476
407
  }
477
408
  // `.stepSwitch` routes its exits as ARMS, so the `.stepToList` bookkeeping
478
409
  // would report every one of them unrouted. STEP_SWITCH_EXIT_UNROUTED is
@@ -527,10 +458,10 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
527
458
  level, code, message, step: stepName,
528
459
  ...(suggestion ? { suggestion } : {}),
529
460
  });
530
- checkEventSubscription(s.spec.subscription, `waitForEvent "${stepName}"`, push('error'), push('warning'), eventParamsFor(s.spec.subscription, opts));
531
- checkEventResolution(s.spec.subscription, `waitForEvent "${stepName}"`, 'event', opts, push('error'), push('warning'), eventParamsFor(s.spec.subscription, opts));
532
- checkBindingLabels(`waitForEvent "${stepName}"`, s.spec.subscription, opts, push('error'), eventTarget(s.spec.subscription), { id: stepName, connector: eventTarget(s.spec.subscription)?.key });
533
- checkBindingPresence(`waitForEvent "${stepName}"`, s.spec.subscription, push('error'), eventTarget(s.spec.subscription));
461
+ // What compile refuses (the event itself, its filters, its binding
462
+ // labels) is the serializer's, reported from its run.
463
+ checkEventSubscription(s.spec.subscription, `waitForEvent "${stepName}"`, push('error'), push('warning'), eventParamsFor(s.spec.subscription, opts), false);
464
+ checkEventResolution(s.spec.subscription, `waitForEvent "${stepName}"`, 'event', opts, push('error'), push('warning'), eventParamsFor(s.spec.subscription, opts), false);
534
465
  }
535
466
  if (s.spec.kind === 'subflow') {
536
467
  // Only a real child goes in the map: a missing one is reported by
@@ -689,12 +620,6 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
689
620
  // clashes, and a read of it is a read of a merge — nothing.
690
621
  const fork = parallelFork(s.options);
691
622
  if (fork) {
692
- if (stepNames.has(fork.name)) {
693
- diags.push({
694
- level: 'error', code: 'DUP_STEP', step: s.name,
695
- message: `Duplicate step name "${fork.name}": the fork of parallel "${s.name}" and another step share it.`,
696
- });
697
- }
698
623
  stepNames.add(fork.name);
699
624
  declared.add(fork.name);
700
625
  parallelNames.add(fork.name);
@@ -711,15 +636,11 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
711
636
  checkLoopListEnvelope(s, registeredSteps, opts, diags);
712
637
  if (s.options?.completionCondition)
713
638
  addExpr(s.options.completionCondition.js, s.name);
714
- // The same refusal serialize throws, from the same helper: an option or
715
- // a `.break()` the pinned version's definition cannot express (#1186).
716
- // Against the definition the loop compiles against — bundled, else
717
- // carried — as serialize resolves it, so a carried 1.0's `.break()`
718
- // and `parallel: true` are judged here and not first at compile (#1311).
639
+ // An option or a `.break()` the pinned version's definition cannot express
640
+ // is the serializer's LOOP_VERSION_UNSUPPORTED (#1186). The legacy
641
+ // `parallel` warning is judged against the same definition — bundled, else
642
+ // carried — as serialize resolves it (#1311).
719
643
  const loopDef = resolveLoopDefinition(loopVersion(s.options), carriedLookup([built, ...ancestors]));
720
- const versionRefusal = loopVersionRefusal(s.name, s.options, bodyUsesBreak(s.body), loopDef);
721
- if (versionRefusal)
722
- diags.push({ level: 'error', code: 'LOOP_VERSION_UNSUPPORTED', step: s.name, message: versionRefusal });
723
644
  const parallelWarning = loopParallelWarning(s.name, s.options, loopDef);
724
645
  if (parallelWarning)
725
646
  diags.push({ level: 'warning', code: 'LOOP_PARALLEL_LEGACY', step: s.name, message: parallelWarning });
@@ -753,24 +674,11 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
753
674
  diags.push({ level: 'warning', code: 'EMPTY_LOOP', message: `Do-while "${s.name}" has an empty body.`, step: s.name });
754
675
  walk(s.body, true);
755
676
  }
756
- else if (s.kind === 'break') {
757
- if (!inLoop) {
758
- diags.push({
759
- level: 'error', code: 'BREAK_OUTSIDE_LOOP',
760
- message: '.break() is only valid inside a .loop() or .doWhile() body.',
761
- });
762
- }
763
- }
764
677
  else if (s.kind === 'continue') {
765
- if (!inLoop) {
766
- diags.push({
767
- level: 'error', code: 'CONTINUE_OUTSIDE_LOOP',
768
- message: '.continue() is only valid inside a .loop() or .doWhile() body: it goes on to the container\'s next iteration.',
769
- });
770
- }
771
- else if (inLoop === 'arm') {
678
+ // Outside any container it is the serializer's CONTINUE_OUTSIDE_LOOP (and
679
+ // `.break()`'s BREAK_OUTSIDE_LOOP).
680
+ if (inLoop === 'arm')
772
681
  diags.push(loopExitInArm());
773
- }
774
682
  }
775
683
  else if (s.kind === 'return') {
776
684
  // A named End (`.return({…}, { name })`) is a node merge can place a
@@ -784,16 +692,12 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
784
692
  // AFTER the dispatch chain, not inside it: `s` was normalised to an action
785
693
  // above, so an `else if` here would be unreachable — which it silently was
786
694
  // on the first cut. The action rules have run by now; what is left is the
787
- // arms and the three things only this step can get wrong.
695
+ // arms and the things only this step can get wrong. A step with no exits
696
+ // to route, and an arm for an exit it does not declare, are the
697
+ // serializer's STEP_SWITCH_NO_EXITS / STEP_SWITCH_EXIT_UNKNOWN.
788
698
  if (s0.kind === 'stepSwitch') {
789
699
  const exits = stepExits(s0.spec, s0.options?.version);
790
700
  if (!hasExits(exits)) {
791
- diags.push({
792
- level: 'error', code: 'STEP_SWITCH_NO_EXITS', step: s0.name,
793
- message: `.stepSwitch("${s0.name}") routes this step's declared exits, but ${exits.reason}, `
794
- + `so there are none to route.`,
795
- ...(exits.suggestion ? { suggestion: exits.suggestion } : {}),
796
- });
797
701
  for (const c of s0.cases)
798
702
  walk(c.body, inLoop);
799
703
  }
@@ -801,23 +705,14 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
801
705
  const seenExit = new Set();
802
706
  for (const c of s0.cases) {
803
707
  const exit = exits.find((e) => e.value === c.value);
804
- if (!exit) {
805
- const nearest = nearestName(c.value, exits.map((e) => e.value));
806
- diags.push({
807
- level: 'error', code: 'STEP_SWITCH_EXIT_UNKNOWN', step: s0.name,
808
- message: `.stepSwitch("${s0.name}") has an arm for ${JSON.stringify(c.value)}, which is not `
809
- + `an exit it declares — those are ${exits.map((e) => JSON.stringify(e.value)).join(', ')}.`,
810
- ...(nearest !== undefined ? { suggestion: `value: '${nearest}'` } : {}),
811
- });
812
- }
813
- else if (seenExit.has(exit.port)) {
708
+ if (exit !== undefined && seenExit.has(exit.port)) {
814
709
  diags.push({
815
710
  level: 'error', code: 'STEP_SWITCH_EXIT_DUP', step: s0.name,
816
711
  message: `.stepSwitch("${s0.name}") has two arms for ${JSON.stringify(c.value)}. A port takes `
817
712
  + `exactly one outgoing edge, so put everything that exit needs in one arm.`,
818
713
  });
819
714
  }
820
- else {
715
+ else if (exit !== undefined) {
821
716
  seenExit.add(exit.port);
822
717
  }
823
718
  if (c.body.length === 0) {
@@ -893,32 +788,14 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
893
788
  reportUnreachableHitlOutcomes(hitlChoiceNoPorts, exprs, diags);
894
789
  checkRegisteredFamilies(registeredSteps, unresolvedSteps, stepNames, diags);
895
790
  checkPackageNodes(registeredSteps, diags);
896
- // The ids this scope's other nodes get, for the file-wide nodeId check in `check`.
897
- for (const n of stepNames)
898
- if (!ledger.taken.has(n))
899
- ledger.taken.set(n, `the id of step "${n}"`);
900
- if (!ledger.taken.has(triggerId))
901
- ledger.taken.set(triggerId, 'the id of the trigger');
902
- for (const ep of built.entryPoints ?? [])
903
- if (!ledger.taken.has(ep.id))
904
- ledger.taken.set(ep.id, `the id of entry point "${ep.id}"`);
905
- // Entry points: unique roots with valid triggers, colliding with nothing.
906
- const epIds = new Set();
791
+ // Entry points: valid triggers. An id that collides with a step, the trigger
792
+ // or another entry point is the serializer's ENTRY_POINT_DUP_ID.
907
793
  for (const ep of built.entryPoints ?? []) {
908
- if (epIds.has(ep.id) || ep.id === triggerId || ep.id === 'start') {
909
- diags.push({
910
- level: 'error', code: 'ENTRY_POINT_DUP_ID', step: ep.id,
911
- message: `.entryPoint("${ep.id}"): the id collides with the default trigger or another entry point.`,
912
- });
913
- }
914
- epIds.add(ep.id);
915
794
  const maybeCustomEp = ep.trigger;
916
795
  if (!('kind' in maybeCustomEp) && maybeCustomEp instanceof FlowNode) {
917
- const own = maybeCustomEp.diagnostics();
918
- for (const d of own) {
796
+ for (const d of maybeCustomEp.diagnostics()) {
919
797
  diags.push({ level: d.severity, code: d.code, message: d.message, step: ep.id });
920
798
  }
921
- checkContribution(maybeCustomEp, ep.options?.version, ep.id, own.some((d) => d.severity === 'error'), diags);
922
799
  }
923
800
  else {
924
801
  checkTrigger(ep.trigger, diags, opts, ep.id);
@@ -952,15 +829,9 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
952
829
  }
953
830
  walk(ep.steps);
954
831
  }
955
- // After the entry-point loop, so prefix steps are in `stepNames`: serialize
956
- // claims body and prefix step ids together (#1249).
957
- checkStepIds(stepNames, ancestors.length === 0 ? triggerId : undefined, diags);
958
- if (built.entryPoints?.length && !built.steps.some((st) => 'name' in st && st.kind !== 'return')) {
959
- diags.push({
960
- level: 'error', code: 'ENTRY_POINT_NO_SHARED_BODY',
961
- message: 'Entry points join the shared body, but this flow has no shared step. Add one, or end each prefix terminally.',
962
- });
963
- }
832
+ // Two steps whose names give one node id, or a step named like the trigger,
833
+ // are the serializer's STEP_ID_COLLISION (#1249); entry points with no shared
834
+ // step to join are its ENTRY_POINT_NO_SHARED_BODY.
964
835
  const conversationRoots = [
965
836
  ...(built.trigger?.kind === 'conversation' ? [triggerId] : []),
966
837
  ...(built.entryPoints ?? []).filter((ep) => ep.trigger.kind === 'conversation').map((ep) => ep.id),
@@ -972,9 +843,7 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
972
843
  + `${conversationRoots.map((id) => `"${id}"`).join(', ')}. Keep one and give the others another trigger.`,
973
844
  });
974
845
  }
975
- // The root trigger and entry points hold node ids of their own; a subflow's
976
- // start is the SDK's to name, so it reserves nothing.
977
- checkStepRefs(built, diags, ancestors.length === 0 ? [triggerId, ...(built.entryPoints ?? []).map((ep) => ep.id)] : []);
846
+ checkStepRefs(built, diags);
978
847
  checkArmScopedReads(built, diags);
979
848
  // A fire-and-forget dispatch publishes no output: any read of it would wait
980
849
  // on a value the run will never produce. Error reads stay legal — dispatch
@@ -1321,29 +1190,25 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
1321
1190
  for (const [stepName, child] of subflowChildren) {
1322
1191
  if (ancestors.includes(child) || child === built)
1323
1192
  continue;
1324
- for (const d of checkFlow(child, [...ancestors, built], opts, ledger)) {
1325
- diags.push({
1193
+ for (const d of checkFlow(child, [...ancestors, built], opts)) {
1194
+ const placed = {
1326
1195
  ...d,
1327
1196
  code: d.code,
1328
1197
  message: `In subflow "${stepName}": ${d.message}`,
1329
1198
  step: d.step ? `${stepName} › ${d.step}` : stepName,
1330
- });
1199
+ };
1200
+ if (OWN_NODE_ERRORS.has(d))
1201
+ OWN_NODE_ERRORS.add(placed);
1202
+ diags.push(placed);
1331
1203
  }
1332
1204
  }
1333
1205
  return diags;
1334
1206
  }
1335
- /** What `registry prepare` takes for an event subscription: its connector key and curated event name. */
1336
- function eventTarget(sub) {
1337
- const { connector, event } = (sub ?? {});
1338
- return typeof connector === 'string' && connector !== ''
1339
- ? { key: connector, ...(typeof event === 'string' && event !== '' ? { action: event } : {}) }
1340
- : undefined;
1341
- }
1342
1207
  /**
1343
1208
  * The connector rules live in `core/connector-checks.ts`, shared with BPMN —
1344
1209
  * they were always a function of the connector corpus and the step's own
1345
1210
  * `{ key, action, version, object, inputs, connection, folder }`, never of
1346
- * anything flow-shaped. These three adapters add what IS flow-shaped: the
1211
+ * anything flow-shaped. These adapters add what IS flow-shaped: the
1347
1212
  * `Connector step "…"` vocabulary, the `step` field on the diagnostic, and the
1348
1213
  * `$vars`/`Expr` dialect its inputs are written in.
1349
1214
  */
@@ -1355,22 +1220,13 @@ function connectorSite(step, diags) {
1355
1220
  }
1356
1221
  function checkConnectorLookups(step, spec, opts, diags) {
1357
1222
  const { where, report } = connectorSite(step, diags);
1358
- coreCheckConnectorLookups(where, spec, opts, FLOW_EXPR_DIALECT, report);
1223
+ // An unresolved lookup is the serializer's LOOKUP_UNRESOLVED.
1224
+ coreCheckConnectorLookups(where, spec, opts, FLOW_EXPR_DIALECT, report, false);
1359
1225
  }
1360
1226
  function checkConnectorSchema(step, spec, opts, diags) {
1361
1227
  const { where, report } = connectorSite(step, diags);
1362
1228
  coreCheckConnectorSchema(where, spec, opts, FLOW_EXPR_DIALECT, report);
1363
1229
  }
1364
- /**
1365
- * The core contract (fields with a library, names with a bindings.json), plus
1366
- * BINDING_MISSING, which needs neither: an absent label is wrong whatever the
1367
- * file says.
1368
- */
1369
- function checkConnectorContract(step, spec, opts, diags) {
1370
- const { where, report } = connectorSite(step, diags);
1371
- coreCheckConnectorContract(where, spec, opts, report, step);
1372
- checkBindingPresence(`connector "${step}"`, spec, (code, message) => report({ level: 'error', code, message }), { key: spec.key, action: spec.action });
1373
- }
1374
1230
  /**
1375
1231
  * Reject a wrapper hop when the runtime shape of a list has been measured: the
1376
1232
  * step the loop reads says what its list output looks like (`listShape`, a
@@ -1607,14 +1463,8 @@ function checkSubflow(step, child, inputs, parent, ancestors, diags) {
1607
1463
  + `trigger of its own — the schedule would be dropped. Remove the .trigger() from "${child.id}" `
1608
1464
  + `(\`.trigger(manual(), { label })\` is allowed, to name the child's start node).`);
1609
1465
  }
1610
- // `{ shared }` merges one input across a flow's ROOTS (#924). A subflow has
1611
- // one root, its caller, so there is nothing to merge; serialize refuses it.
1612
- for (const inp of child.inputs) {
1613
- if (inp.shared === undefined)
1614
- continue;
1615
- at('SHARED_INPUT_IN_SUBFLOW', `Subflow "${step}" runs "${child.id}", whose input "${inp.name}" has { shared: "${inp.shared}" }. A subflow has `
1616
- + `one root — its caller — so there is nothing to merge. Read the input with input('${inp.name}') inside the child.`);
1617
- }
1466
+ // `{ shared }` merges one input across a flow's ROOTS (#924); a subflow has
1467
+ // one root, its caller: the serializer's SHARED_INPUT_IN_SUBFLOW.
1618
1468
  const declared = new Map(child.inputs.map((i) => [i.name, String(i.type)]));
1619
1469
  const given = inputs ?? {};
1620
1470
  for (const key of Object.keys(given)) {
@@ -1849,34 +1699,20 @@ const INTERVAL_WORDS = [
1849
1699
  */
1850
1700
  /**
1851
1701
  * Every declared name must become exactly one global, because `flow validate`
1852
- * rejects a repeated id (`[variables.globals] Duplicate variable ID`;
1853
- * VARIABLE_NAME_COLLISION, flow-builder-sdk#890). The planning — including the one collision that is NOT an error, a same-named
1854
- * `.output()` + `.var()`, which merges into one `out` global carrying the var's
1855
- * default — lives in `planFlowGlobals`, shared with the serializer, so `check`
1856
- * and the emitted file cannot disagree about it (flow-builder-sdk#899: `check`
1857
- * said `Errors: 0`, validate refused the file).
1702
+ * rejects a repeated id (`[variables.globals] Duplicate variable ID`,
1703
+ * flow-builder-sdk#890). The plans that decide it (`planFlowGlobals`,
1704
+ * `planSharedInputs`, `planOutputBacking`) are the serializer's, and so are
1705
+ * their refusals (VARIABLE_NAME_COLLISION, OUTPUT_VAR_MISMATCH, SHARED_INPUT_*,
1706
+ * OUTPUT_BACKING_COLLISION). What is left here is the shared-input plan's
1707
+ * warnings, which compile does not raise.
1858
1708
  */
1859
1709
  function checkGlobalNames(built, diags) {
1860
- // `{ shared }` inputs (#924) may add `.var()`s; the plan is the serializer's
1861
- // own (`emittableGlobals`), so check and the emitted file agree on them.
1862
1710
  const shared = planSharedInputs({ roots: sharedInputRoots(built), outputs: built.outputs ?? [], vars: built.vars ?? [] });
1863
1711
  for (const p of shared.problems) {
1712
+ if (p.level !== 'warning')
1713
+ continue;
1864
1714
  diags.push({ level: p.level, code: p.code, message: p.message, ...(p.suggestion ? { suggestion: p.suggestion } : {}) });
1865
1715
  }
1866
- const vars = [...(built.vars ?? []), ...shared.autoVars];
1867
- const plan = planFlowGlobals({ inputs: built.inputs ?? [], outputs: built.outputs ?? [], vars });
1868
- for (const p of plan.problems) {
1869
- diags.push({ level: 'error', code: p.code, message: p.message, ...(p.suggestion ? { suggestion: p.suggestion } : {}) });
1870
- }
1871
- if (plan.problems.length)
1872
- return;
1873
- // An output the flow sets from inside is emitted with an `inout` Variable
1874
- // `<output>Value` behind it (#1092); that id must be free. Same plan as
1875
- // `emittableGlobals`, so a name check passes is one serialize can emit.
1876
- const sharedOutputs = new Set(shared.targets.filter((t) => t.declaredAs === 'output').map((t) => t.name));
1877
- for (const p of planOutputBacking(built, plan.outputs, plan.vars, sharedOutputs).problems) {
1878
- diags.push({ level: 'error', code: p.code, message: p.message, ...(p.suggestion ? { suggestion: p.suggestion } : {}) });
1879
- }
1880
1716
  }
1881
1717
  /**
1882
1718
  * `{ updates }` writes a declared flow variable when the node completes. The
@@ -1932,8 +1768,6 @@ function checkNodeUpdates(built, diags) {
1932
1768
  visit(built.steps);
1933
1769
  }
1934
1770
  function checkTrigger(spec, diags, opts = {}, entryPointId) {
1935
- const where = entryPointId === undefined ? undefined : `onEvent() entry point "${entryPointId}"`;
1936
- const call = entryPointId === undefined ? '.trigger()' : `.entryPoint("${entryPointId}")`;
1937
1771
  const push = (level) => (code, message, suggestion) => {
1938
1772
  diags.push({ level, code, message, step: entryPointId ?? 'trigger', ...(suggestion ? { suggestion } : {}) });
1939
1773
  };
@@ -1942,10 +1776,9 @@ function checkTrigger(spec, diags, opts = {}, entryPointId) {
1942
1776
  // object without its factory) or a typo'd kind reaches here intact.
1943
1777
  if (spec?.kind === 'event') {
1944
1778
  const sub = spec.subscription;
1945
- checkEventSubscription(sub, 'trigger', at, push('warning'), eventParamsFor(sub, opts));
1946
- checkEventResolution(sub, 'trigger', 'trigger', opts, at, push('warning'), eventParamsFor(sub, opts));
1947
- checkBindingLabels('event trigger', sub, opts, at, eventTarget(sub), { id: 'trigger', connector: eventTarget(sub)?.key });
1948
- checkBindingPresence(where ?? 'event trigger', sub, at, eventTarget(sub));
1779
+ // As for waitForEvent: what compile refuses is the serializer's.
1780
+ checkEventSubscription(sub, 'trigger', at, push('warning'), eventParamsFor(sub, opts), false);
1781
+ checkEventResolution(sub, 'trigger', 'trigger', opts, at, push('warning'), eventParamsFor(sub, opts), false);
1949
1782
  return;
1950
1783
  }
1951
1784
  // manual() is the explicit spelling of the default — nothing to validate.
@@ -1959,14 +1792,9 @@ function checkTrigger(spec, diags, opts = {}, entryPointId) {
1959
1792
  // platform started is the trigger's own output, declared by the definition.
1960
1793
  if (spec?.kind === 'conversation' || spec?.kind === 'voice')
1961
1794
  return;
1962
- if (spec?.kind !== 'scheduled') {
1963
- at('TRIGGER_UNKNOWN_KIND', `${call} was given ${JSON.stringify(spec?.kind)}, which is not a trigger. `
1964
- + `Wrap it in a factory: scheduled({ every: 'R/PT1H' }) for a schedule, `
1965
- + `onEvent({ connector, event, where, connection, folder }) to start on a connector event, formTrigger() to start from a `
1966
- + `submitted form, or conversationTrigger() to start on a new conversation. Omit .trigger() entirely `
1967
- + `for the manual trigger (the default).`, `scheduled({ every: 'R/PT1H' })`);
1795
+ // Anything but a schedule here is not a trigger: the serializer's TRIGGER_UNKNOWN_KIND.
1796
+ if (spec?.kind !== 'scheduled')
1968
1797
  return;
1969
- }
1970
1798
  const inputs = spec.inputs;
1971
1799
  const raw = inputs?.every;
1972
1800
  if (raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '')) {
@@ -2262,14 +2090,9 @@ function checkParallel(s, diags) {
2262
2090
  + `terminate its own arm, or move the earlier step(s) before the \`.parallel(…)\` call.`, `.parallel('${s.name}', [(a) => a.terminate('${term.name}'), …])`);
2263
2091
  }
2264
2092
  });
2265
- arms.forEach((arm, i) => {
2266
- const steps = Array.isArray(arm) ? arm : [];
2267
- const fanIn = hitlFanInto(steps);
2268
- if (fanIn)
2269
- diags.push(fanInDiag(s.name, i, steps, fanIn));
2270
- for (const h of hitlEndHazards(steps))
2271
- diags.push(endDiag(s.name, h));
2272
- });
2093
+ // A human task whose alternative outcomes would each enter the join, or whose
2094
+ // End outcome would starve it, is the serializer's HITL_FAN_OUT_INTO_JOIN /
2095
+ // HITL_END_OUTCOME_IN_PARALLEL (worded from `flow-parallel-hitl.ts`).
2273
2096
  // An arm that ends the flow reaches an End node instead of the Merge, so it
2274
2097
  // contributes NO incoming edge. Verified on the emitted graph: with two arms
2275
2098
  // and one returning, the Merge has a single incoming edge and is no longer a
@@ -2293,202 +2116,6 @@ function checkParallel(s, diags) {
2293
2116
  });
2294
2117
  }
2295
2118
  }
2296
- /**
2297
- * Whether a `.parallel` arm hands its join an edge, as serialize wires it: it
2298
- * has a step, and `armTails` (serialize's `emit` tail for tail) leaves it at
2299
- * least one tail. So an arm that ends by `.return`, `.terminate`, `.break`,
2300
- * `.continue` or a `.stepToRef` on the default port does not, and neither does
2301
- * one that ends in a `.branch` / `.switch` / `.stepSwitch` every arm of which
2302
- * ends, or in a nested `.parallel` none of whose arms reaches its own join.
2303
- * When no arm hands on a tail, serialize emits no Merge at all (#1267), so the
2304
- * join rules (PARALLEL_ARM_RETURNS, REF_INTO_PARALLEL_ARM,
2305
- * REF_OUT_OF_PARALLEL_ARM) have no join to protect.
2306
- */
2307
- function armReachesJoin(arm) {
2308
- return arm.length > 0 && armTails(arm, [{ from: '' }]).length > 0;
2309
- }
2310
- /**
2311
- * The edges a step list hands on, mirroring serialize's `emit` tail for tail.
2312
- *
2313
- * `.branch` / `.switch` / `.stepSwitch` hand on every arm's tails (an empty arm
2314
- * hands on the port it started from), a `.parallel` its Merge when an arm
2315
- * reaches it and nothing otherwise, a fan-out human task one per continuing
2316
- * outcome, a routed one its primary outcome; `.return` / `.terminate` / `.break`
2317
- * and a `stepToRef('output', …)` hand on none, `.stepToList` leaves the path as
2318
- * it was, and every other step hands on its own single edge.
2319
- */
2320
- function armTails(steps, incoming) {
2321
- let tails = incoming;
2322
- for (const st of steps) {
2323
- if (st.kind === 'stepToList')
2324
- continue;
2325
- if (st.kind === 'stepToRef') {
2326
- if (st.port === 'output')
2327
- tails = [];
2328
- continue;
2329
- }
2330
- if (st.kind === 'return' || st.kind === 'terminate' || st.kind === 'break' || st.kind === 'continue')
2331
- return [];
2332
- if (st.kind === 'action') {
2333
- tails = actionTails(st.name, st.spec, st.options?.version);
2334
- }
2335
- else if (st.kind === 'stepSwitch') {
2336
- const spec = st.spec;
2337
- const outcomes = spec.kind === 'hitl' && hitlDerivesOutcomeHandles(spec.inputs?.variant);
2338
- tails = st.cases.flatMap((c) => armTails(c.body, [{ from: st.name, ...(outcomes ? { outcome: String(c.value) } : {}) }])
2339
- .map((t) => (outcomes && !t.humanSwitch ? { ...t, humanSwitch: { step: st.name, outcome: String(c.value) } } : t)));
2340
- }
2341
- else if (st.kind === 'branch') {
2342
- tails = [...armTails(st.then, [{ from: st.name }]), ...armTails(st.otherwise, [{ from: st.name }])];
2343
- }
2344
- else if (st.kind === 'switch') {
2345
- tails = [
2346
- ...st.cases.flatMap((c) => armTails(c.body, [{ from: st.name }])),
2347
- ...(st.default !== undefined ? armTails(st.default, [{ from: st.name }]) : []),
2348
- ];
2349
- }
2350
- else if (st.kind === 'parallel') {
2351
- // serialize joins a nested `.parallel` at its Merge, the one edge it hands
2352
- // on, and emits no Merge, so no edge, when no arm hands on a tail (#1267).
2353
- // An empty arm hands on the path it started from, which the Merge joins.
2354
- const arms = Array.isArray(st.arms) ? st.arms : [];
2355
- tails = arms.some((arm) => armTails(arm, tails).length > 0) ? [{ from: st.name }] : [];
2356
- }
2357
- else {
2358
- tails = [{ from: st.name ?? st.kind }];
2359
- }
2360
- }
2361
- return tails;
2362
- }
2363
- function actionTails(name, spec, version) {
2364
- if (spec.kind !== 'hitl' || !hitlDerivesOutcomeHandles(spec.inputs?.variant))
2365
- return [{ from: name }];
2366
- const model = hitlExitModel(spec.inputs, version);
2367
- if (model.shape === 'placeholder')
2368
- return [{ from: name }];
2369
- if (model.shape === 'per-outcome')
2370
- return model.exits.slice(0, 1).map((e) => ({ from: name, outcome: e.value }));
2371
- return model.exits.filter((e) => !e.endsProcess).map((e) => ({ from: name, outcome: e.value }));
2372
- }
2373
- /**
2374
- * The tails of a `.parallel` arm when they would starve its join: two or more
2375
- * alternative edges — only one fires — at least one of which is a human task's
2376
- * outcome edge, or the end of a human-task `.stepSwitch` arm. The join waits
2377
- * for a token on every incoming flow.
2378
- *
2379
- * However the task got there, it is the same graph: a fan-out task at the arm's
2380
- * end (its outcomes are the alternatives), a `.stepSwitch` with an empty arm
2381
- * beside a body arm, a complete `.stepSwitch` with nothing after it (one edge
2382
- * per arm's last step), a task on one side of a `.branch`. serialize refuses the
2383
- * same condition, edge for edge. Rejoining arms with NO human task in them are
2384
- * the older, general case, and not refused here.
2385
- */
2386
- function hitlFanInto(steps) {
2387
- const tails = armTails(steps, [{ from: '(fork)' }]);
2388
- return tails.length >= 2 && tails.some((t) => t.outcome !== undefined || t.humanSwitch !== undefined) ? tails : undefined;
2389
- }
2390
- function fanInDiag(join, arm, steps, tails) {
2391
- const tagged = tails.find((t) => t.outcome !== undefined || t.humanSwitch !== undefined);
2392
- const task = tagged.outcome !== undefined ? tagged.from : tagged.humanSwitch.step;
2393
- if (tails.every((t) => t.from === task && t.outcome !== undefined)) {
2394
- return {
2395
- level: 'error', code: 'HITL_FAN_OUT_INTO_JOIN', step: task,
2396
- message: `Human task "${task}" ends its path inside parallel "${join}", so its outcomes `
2397
- + `${tails.map((t) => t.outcome).join(', ')} each get their own edge into the join. Only one outcome ever `
2398
- + `fires, and the join waits for a token on every incoming edge, so the run never gets past "${join}" — with `
2399
- + `no incident, and \`flow validate\` answers Valid. Add a step after "${task}" inside the arm, so every `
2400
- + `outcome continues to that step and only its one edge reaches the join (the decision is `
2401
- + `out('${task}', 'Action')), or move the task out of the \`.parallel(…)\`.`,
2402
- suggestion: `a.step('${task}', hitl(…)).step('record', …)`,
2403
- };
2404
- }
2405
- const edges = tails.map((t) => (t.outcome !== undefined ? `outcome ${t.outcome} of "${t.from}"`
2406
- : t.humanSwitch ? `"${t.from}" (on outcome ${t.humanSwitch.outcome} of "${t.humanSwitch.step}")` : `"${t.from}"`));
2407
- const last = [...steps].reverse().find((x) => x.kind !== 'stepToList' && x.kind !== 'stepToRef');
2408
- return {
2409
- level: 'error', code: 'HITL_FAN_OUT_INTO_JOIN', step: task,
2410
- message: `Parallel "${join}" arm ${arm + 1} ends on ${tails.length} alternative paths, and each gets its own edge `
2411
- + `into the join: ${edges.join(', ')}. Only one of them runs, and the join waits for a token on every incoming `
2412
- + `edge, so the run never gets past "${join}" — with no incident, and \`flow validate\` answers Valid. Add a `
2413
- + `step at the end of the arm${last?.name ? `, after "${last.name}"` : ''}, so the paths meet on that step and `
2414
- + `only its one edge reaches the join, or move human task "${task}" out of the \`.parallel(…)\`.`,
2415
- suggestion: `….step('record', …) // last step of arm ${commentText(arm + 1)}`,
2416
- };
2417
- }
2418
- /**
2419
- * Human tasks in one `.parallel` arm that continue every outcome while some of
2420
- * them are `action: 'End'`. The End outcome's path stops at its End node, so the
2421
- * join waits for the continuing path that did not run.
2422
- *
2423
- * Recurses into `.branch` / `.switch` / `.stepSwitch` bodies, which reach the
2424
- * join too. Not into a loop body (the loop node is what reaches the join) or a
2425
- * nested `.parallel` (its own join is checked on its own).
2426
- */
2427
- function hitlEndHazards(steps) {
2428
- const found = [];
2429
- for (const st of steps) {
2430
- if (st.kind === 'action' && st.spec.kind === 'hitl') {
2431
- const model = hitlExitModel(st.spec.inputs, st.options?.version);
2432
- if (model.shape !== 'every-outcome')
2433
- continue;
2434
- const ending = model.exits.filter((e) => e.endsProcess).map((e) => e.value);
2435
- if (ending.length > 0 && ending.length < model.exits.length)
2436
- found.push({ step: st.name, outcomes: ending, via: 'action-end' });
2437
- }
2438
- else if (st.kind === 'branch') {
2439
- found.push(...hitlEndHazards(st.then), ...hitlEndHazards(st.otherwise));
2440
- }
2441
- else if (st.kind === 'switch') {
2442
- for (const c of st.cases)
2443
- found.push(...hitlEndHazards(c.body));
2444
- if (st.default)
2445
- found.push(...hitlEndHazards(st.default));
2446
- }
2447
- else if (st.kind === 'stepSwitch') {
2448
- // An outcome with no arm gets an End (serialize's `endExit`), and so does
2449
- // an arm that ends in `.return()`. Beside an arm that continues, that End
2450
- // is where this arm's token stops on that outcome.
2451
- const spec = st.spec;
2452
- const exits = spec.kind === 'hitl' && hitlDerivesOutcomeHandles(spec.inputs?.variant)
2453
- ? stepExits(st.spec, st.options?.version) : undefined;
2454
- if (exits && hasExits(exits)) {
2455
- const stopped = exits.filter((e) => {
2456
- const c = st.cases.find((x) => x.value === e.value);
2457
- return !c || (armTails(c.body, [{ from: st.name }]).length === 0 && stopsAtEnd(c.body));
2458
- }).map((e) => e.value);
2459
- if (stopped.length > 0 && armTails([st], [{ from: '(fork)' }]).length > 0) {
2460
- found.push({ step: st.name, outcomes: stopped, via: 'step-switch' });
2461
- }
2462
- }
2463
- for (const c of st.cases)
2464
- found.push(...hitlEndHazards(c.body));
2465
- }
2466
- }
2467
- return found;
2468
- }
2469
- function endDiag(join, h) {
2470
- const list = h.outcomes.join(', ');
2471
- if (h.via === 'step-switch') {
2472
- return {
2473
- level: 'error', code: 'HITL_END_OUTCOME_IN_PARALLEL', step: h.step,
2474
- message: `Human task "${h.step}" is a .stepSwitch inside parallel "${join}", and ${h.outcomes.length === 1
2475
- ? `outcome ${list} has` : `outcomes ${list} have`} no arm, or an arm that ends in .return(). That path `
2476
- + `stops at an End node, while the join waits for a token from this arm on the path that continues, so the `
2477
- + `run never gets past "${join}" — with no incident, and \`flow validate\` answers Valid. Give every outcome `
2478
- + `an arm that continues (and move a .return() after the \`.parallel(…)\`), or move the task out of the `
2479
- + `\`.parallel(…)\`.`,
2480
- suggestion: `{ value: '${h.outcomes[0]}', body: (b) => b.step(…) }`,
2481
- };
2482
- }
2483
- return {
2484
- level: 'error', code: 'HITL_END_OUTCOME_IN_PARALLEL', step: h.step,
2485
- message: `Human task "${h.step}" is inside parallel "${join}", and ${h.outcomes.length === 1
2486
- ? `outcome ${list} is` : `outcomes ${list} are`} action: 'End'. `
2487
- + `That path stops at its own End node, while the join waits for a token from this arm on its `
2488
- + `continuing path, so the run never gets past "${join}". Drop action: 'End' there and act on `
2489
- + `out('${h.step}', 'Action') after the join, or move the task out of the \`.parallel(…)\`.`,
2490
- };
2491
- }
2492
2119
  /**
2493
2120
  * Diagnose a `transform` step.
2494
2121
  *
@@ -2504,19 +2131,6 @@ function endDiag(join, h) {
2504
2131
  * reference to hide — which is what the checks below enforce for the cases
2505
2132
  * where types have been stripped at run time.
2506
2133
  */
2507
- /**
2508
- * Why a base or quick-form task continues every outcome to the next step, as a
2509
- * clause for a diagnostic. `pinned` is the step's `{ version }`.
2510
- */
2511
- function fanOutWhy(why, pinned) {
2512
- switch (why) {
2513
- case 'outcomePorts: false': return 'outcomePorts: false says so';
2514
- case 'quick-form': return 'a quick-form task does not unless it sets outcomePorts: true';
2515
- case 'pinned': return `{ version: '${pinned}' } pins it without outcomePorts: true`;
2516
- case 'one outcome': return 'it has a single outcome';
2517
- case 'every outcome ends': return "every outcome is action: 'End'";
2518
- }
2519
- }
2520
2134
  /**
2521
2135
  * HITL_OUTCOMES_UNREACHABLE — a human task offers the reviewer a CHOICE that
2522
2136
  * nothing downstream can act on.
@@ -2703,7 +2317,7 @@ function registeredKind(spec) {
2703
2317
  * which serialize enforces (#942) and the type's tests pin; an author cannot
2704
2318
  * cause the gap, so there is no author diagnostic to report.
2705
2319
  */
2706
- function checkRegisteredStep(entry, s, exprs, errorSource, opts, diags, typeOf, triggerId, ledger, carried) {
2320
+ function checkRegisteredStep(entry, s, exprs, errorSource, opts, diags, typeOf, triggerId, carried) {
2707
2321
  const spec = s.spec;
2708
2322
  /** The step's node-local rules and expressions, as `impl` defines them. */
2709
2323
  const runRules = (impl, version = impl.version, carriedDef) => {
@@ -2718,30 +2332,14 @@ function checkRegisteredStep(entry, s, exprs, errorSource, opts, diags, typeOf,
2718
2332
  return name ? { name, type: typeOf.get(name) } : undefined;
2719
2333
  },
2720
2334
  options: opts,
2721
- bindingLabels: (where, labels, use, target) => checkBindingLabels(where, labels, opts, (code, message) => diags.push({ level: 'error', code, step: s.name, message }), target, use),
2335
+ bindingLabels: (where, labels, _use, target) => checkBindingLabels(where, labels, opts, (code, message) => diags.push({ level: 'error', code, step: s.name, message }), target),
2336
+ resources: { derivedNodeId: () => { }, claimNodeId: () => undefined, claimFolder: () => undefined },
2337
+ // The operation, its field contract and its binding labels are the
2338
+ // serializer's (CONNECTOR_*, BINDING_*), reported from its run.
2722
2339
  connector: (c) => {
2723
2340
  const spec = c;
2724
2341
  checkConnectorLookups(s.name, spec, opts, diags);
2725
2342
  checkConnectorSchema(s.name, spec, opts, diags);
2726
- checkConnectorContract(s.name, spec, opts, diags);
2727
- },
2728
- resources: {
2729
- derivedNodeId: (id, what) => { if (!ledger.taken.has(id))
2730
- ledger.taken.set(id, what); },
2731
- claimNodeId: (id, what) => {
2732
- const other = ledger.explicit.get(id);
2733
- if (other === undefined)
2734
- ledger.explicit.set(id, { step: s.name, what });
2735
- return other;
2736
- },
2737
- claimFolder: (source) => {
2738
- const key = source.toLowerCase();
2739
- const other = ledger.agentSources.get(key);
2740
- if (other !== undefined && other !== s.name)
2741
- return other;
2742
- ledger.agentSources.set(key, s.name);
2743
- return undefined;
2744
- },
2745
2343
  },
2746
2344
  };
2747
2345
  impl.check?.(spec, ctx);
@@ -2771,7 +2369,7 @@ function checkRegisteredStep(entry, s, exprs, errorSource, opts, diags, typeOf,
2771
2369
  // A select() that picks an unregistered version is the node type's bug, not the author's pin.
2772
2370
  if (isInternalSdkError(e))
2773
2371
  throw e;
2774
- diags.push({ level: 'error', code: 'NODE_VERSION_UNREGISTERED', step: s.name, message: e.message });
2372
+ // The pin itself is the serializer's NODE_VERSION_UNREGISTERED (`serializerRefusals`).
2775
2373
  // Still report the step's other mistakes in this same pass, with the version it
2776
2374
  // would get unpinned (select(), else the default) — as decompile lifts an
2777
2375
  // unregistered version — so fixing the pin is not followed by a second round.
@@ -2899,12 +2497,6 @@ function collectActionExprs(s, out, errorSource) {
2899
2497
  * registered node type — whose node type has no error port. One spelling for
2900
2498
  * both, so porting a family onto the registry changes no message.
2901
2499
  */
2902
- function typedNoErrorPortMessage(stepName, kind, def) {
2903
- const nodeType = def ? ` (${def.nodeType}@${def.version})` : '';
2904
- return `Step "${stepName}" is a ${kind} step${nodeType}, and that node type has no error port — its definition does not `
2905
- + `set supportsErrorHandling, so the edge would leave a handle the node does not have. Handle the failure `
2906
- + `downstream instead (a delay cannot fail; a wait resumes or does not).`;
2907
- }
2908
2500
  /** The five fields the platform's error envelope declares — no more, no less. */
2909
2501
  // The five the definitions declare, plus the two every probed runtime envelope
2910
2502
  // carried anyway (`response`, `element`) — see ErrorEnvelopeField in core/expr.ts.
@@ -2914,56 +2506,27 @@ const ERROR_ENVELOPE_FIELDS = ['code', 'message', 'detail', 'category', 'status'
2914
2506
  * mistakes at build time (no previous step, two handlers on one node, an empty
2915
2507
  * handler); these are the ones only a checker can see.
2916
2508
  *
2917
- * Whether the step has an error port at all: every family is decided by a
2918
- * definition, never by a list:
2919
- *
2920
- * - a bundled family by its bundled definition (`bundledActionNodeType` +
2921
- * `bundledDefinition`, #1011: `documentClassify` was missing from the old
2922
- * list although `uipath.document.classify@1.0` sets the flag);
2923
- * - a per-tenant family — every one is registered — through its node type's
2924
- * `errorPort`, which asks the definition serialize synthesizes (#1040:
2925
- * `publishedFunction` was refused here although `publishedFunctionDef` sets
2926
- * the flag), or for a connector the library's definition, which check reads
2927
- * through `connectorDefinition` and, without a library, assumes grants the
2928
- * port, as every operation the library serves does.
2929
- *
2930
- * `publishedFunction`'s flag is measured, not assumed: on 2026-10-08 four
2931
- * `flow debug` jobs on alpha/codereval called `acme-echo` with a non-string
2932
- * `message` (null, object, number, array). In each, the function job failed
2933
- * (`Python.FUNCTION_EXECUTION_ERROR`, category User, status 502), the node's
2934
- * error boundary fired, and the handler's `err('message')` read
2935
- * "Failure in the Orchestrator Job".
2936
- *
2937
- * A kind in none of these (`waitEvent`) has no error port: an event wait
2938
- * resumes or does not.
2509
+ * Whether the step has an error port at all is the serializer's
2510
+ * ONERROR_NO_ERROR_PORT: it asks the definition it emits, never a list (a
2511
+ * bundled family's bundled definition, #1011; a per-tenant one's `errorPort`,
2512
+ * #1040; a custom node's own definition, #943). `publishedFunction`'s port is
2513
+ * measured, not assumed: on 2026-10-08 four `flow debug` jobs on
2514
+ * alpha/codereval called `acme-echo` with a non-string `message`, and in each
2515
+ * the node's error boundary fired and the handler read "Failure in the
2516
+ * Orchestrator Job".
2939
2517
  */
2940
2518
  function checkOnError(_handler, step, diags,
2941
2519
  /** The step's registry record, when its kind is registered and its version resolved. */
2942
- placed, opts, carried) {
2520
+ placed, opts) {
2943
2521
  // The builder refuses a handler with no eligible step before it, so a missing
2944
2522
  // source here means a hand-built `BuiltFlow`; there is nothing to say about it.
2945
- if (!step)
2523
+ // A step whose definition has no error port is the serializer's
2524
+ // ONERROR_NO_ERROR_PORT, asked of the definition it emits (#943, #1011). What
2525
+ // is left here is a node type's own refusal of a handler on a port it has.
2526
+ if (!step || step.kind === 'loop' || step.kind === 'doWhile')
2946
2527
  return;
2947
- // A container (`.loop()` / `.doWhile()`) is decided by its bundled definition
2948
- // too. Both set `supportsErrorHandling: true`, and both route a body failure
2949
- // to the container's port, measured: the container's envelope carries the
2950
- // body's message in `detail` and the failing body step's canvas label (its id when it has no `label`) in `element`, and
2951
- // the instance completes. `core.logic.dowhile@1.0` declares no `error` handle,
2952
- // but the flag alone grants it: flow validate adds the handle from the flag.
2953
- if (step.kind === 'loop' || step.kind === 'doWhile') {
2954
- const def = bundledContainerDefinition(step.kind, step.options?.version);
2955
- if (def === undefined || definitionHasErrorPort(def))
2956
- return;
2957
- diags.push({
2958
- level: 'error',
2959
- code: 'ONERROR_NO_ERROR_PORT',
2960
- message: errorPortRefusal(step.name, def),
2961
- step: step.name,
2962
- });
2963
- return;
2964
- }
2965
- // A custom FlowAction or rawNode has no `kind`: ask its DEFINITION, with the
2966
- // product's own rule (definitionHasErrorPort), not the kind list (#943).
2528
+ // A custom node backed by a typed family's bundled definition gets that
2529
+ // family's refusals: the rules are about the node type, not the SDK kind.
2967
2530
  const maybeCustom = step.spec;
2968
2531
  if (!('kind' in maybeCustom) && maybeCustom instanceof FlowNode) {
2969
2532
  let def;
@@ -2972,98 +2535,31 @@ placed, opts, carried) {
2972
2535
  def = resolveContributionDefinition(maybeCustom.contribute(requestedVersion !== undefined ? { requestedVersion } : {}).definition, requestedVersion, `step "${step.name}"`);
2973
2536
  }
2974
2537
  catch {
2975
- // checkContribution already reports a contribution compile cannot lower.
2538
+ // A contribution compile cannot lower is the serializer's CONTRIBUTION_INVALID.
2976
2539
  return;
2977
2540
  }
2978
- // A custom node backed by a typed family's bundled definition gets that
2979
- // family's refusals: the rules are about the node type, not the SDK kind.
2980
- if (def?.nodeType === 'core.action.http') {
2541
+ if (def?.nodeType === 'core.action.http')
2981
2542
  diags.push(plainHttpOnErrorRefusal(step.name));
2982
- return;
2983
- }
2984
- if (definitionHasErrorPort(def))
2985
- return;
2986
- diags.push({
2987
- level: 'error',
2988
- code: 'ONERROR_NO_ERROR_PORT',
2989
- message: errorPortRefusal(step.name, def),
2990
- step: step.name,
2991
- });
2992
- return;
2993
- }
2994
- const spec = step.spec;
2995
- // A registered node type: a bundled one asks its definition at the version it
2996
- // will be emitted at; a per-tenant one answers for itself (its errorPort).
2997
- if (registeredKind(spec)) {
2998
- // An unregistered pin still has a record, with the version it would get
2999
- // unpinned; NODE_VERSION_UNREGISTERED reports the pin itself.
3000
- if (!placed)
3001
- return;
3002
- const { impl } = placed;
3003
- if (impl.definition !== 'bundled' && placed.carried !== undefined) {
3004
- // At a carried version the carried definition answers, as serialize asks
3005
- // it: the type's declaration covers only the versions it registers.
3006
- if (!definitionHasErrorPort(placed.carried)) {
3007
- diags.push({ level: 'error', code: 'ONERROR_NO_ERROR_PORT', message: typedNoErrorPortMessage(step.name, impl.kind, placed.carried), step: step.name });
3008
- return;
3009
- }
3010
- }
3011
- else if (impl.definition !== 'bundled') {
3012
- // The definition serialize will be supplied, when check's options can name
3013
- // it (a connector's, from the library); the type answers without it otherwise.
3014
- const def = impl.checkDefinition?.(placed.spec, opts);
3015
- const port = typeof impl.errorPort === 'function' ? impl.errorPort(spec, def) : impl.errorPort === true;
3016
- if (!port) {
3017
- const named = def !== undefined && typeof def.nodeType === 'string' && typeof def.version === 'string';
3018
- diags.push({
3019
- level: 'error', code: 'ONERROR_NO_ERROR_PORT', step: step.name,
3020
- message: named
3021
- ? declaredErrorPortRefusal(step.name, def.nodeType, def.version)
3022
- : declaredErrorPortRefusal(step.name, impl.typeName, impl.version),
3023
- });
3024
- return;
3025
- }
3026
- }
3027
- else {
3028
- const def = placed.carried ?? bundledDefinition(impl.typeName, impl.version);
3029
- if (def !== undefined && !definitionHasErrorPort(def)) {
3030
- diags.push({ level: 'error', code: 'ONERROR_NO_ERROR_PORT', message: typedNoErrorPortMessage(step.name, impl.kind, def), step: step.name });
3031
- return;
3032
- }
3033
- }
3034
- // The port exists; the node type may still refuse the handler (plain HTTP's rule, once ported).
3035
- impl.checkErrorHandler?.(placed.spec, placed.ctx);
3036
2543
  return;
3037
2544
  }
3038
- // A typed family with a bundled definition: ask the definition serialize will
3039
- // emit, at the version it will emit (#1011), chosen by the same selector
3040
- // serialize uses (#1041). For instance `voiceAgent()` defaults to
3041
- // uipath.agent.voice@1.0 (no error port) and `{ version: '1.1' }` selects @1.1
3042
- // (which has one); `hitl({ exposeError: true })` selects uipath.human-in-the-loop@1.2
3043
- // with no `{ version }` at all.
3044
- // A version the SDK does not bundle (an inline agent's registry version) has
3045
- // no definition here to ask; version resolution reports that, not this check.
3046
- const bundledType = bundledActionNodeType(spec);
3047
- // A version the flow CARRIES (`.definitions()`, #1128) answers the same way
3048
- // a bundled one does: it is the definition serialize will emit.
3049
- const pinned = step.options?.version;
3050
- const bundledDef = bundledType
3051
- ? bundledDefinition(bundledType, pinned) ?? (pinned !== undefined ? carried?.(bundledType, pinned) : undefined)
3052
- : undefined;
3053
- // Every per-tenant family is registered and answers above (its errorPort, or
3054
- // a carried definition); what is left is bundled, or has no definition here.
3055
- const noPort = bundledDef !== undefined
3056
- ? !definitionHasErrorPort(bundledDef)
3057
- : !bundledType;
3058
- if (noPort) {
3059
- diags.push({
3060
- level: 'error',
3061
- code: 'ONERROR_NO_ERROR_PORT',
3062
- message: typedNoErrorPortMessage(step.name, spec.kind, bundledDef),
3063
- step: step.name,
3064
- });
2545
+ // A registered node type (an unregistered pin still has a record, with the
2546
+ // version it would get unpinned) may refuse the handler on a port it has
2547
+ // (plain HTTP's rule, once ported).
2548
+ if (!registeredKind(step.spec) || !placed)
3065
2549
  return;
3066
- }
2550
+ const { impl } = placed;
2551
+ const hasPort = impl.definition !== 'bundled' && placed.carried !== undefined
2552
+ ? definitionHasErrorPort(placed.carried)
2553
+ : impl.definition !== 'bundled'
2554
+ ? (typeof impl.errorPort === 'function'
2555
+ ? impl.errorPort(step.spec, impl.checkDefinition?.(placed.spec, opts))
2556
+ : impl.errorPort === true)
2557
+ : (() => {
2558
+ const def = placed.carried ?? bundledDefinition(impl.typeName, impl.version);
2559
+ return def === undefined || definitionHasErrorPort(def);
2560
+ })();
2561
+ if (hasPort)
2562
+ impl.checkErrorHandler?.(placed.spec, placed.ctx);
3067
2563
  }
3068
2564
  /** `.onError` after a custom node built on the plain `core.action.http` definition (the http family's refusal). */
3069
2565
  function plainHttpOnErrorRefusal(stepName) {
@@ -3225,7 +2721,7 @@ function checkArmScopedReads(built, diags) {
3225
2721
  * Backward targets are ALLOWED. Flow JSON can express a backward edge, so the SDK
3226
2722
  * can author one; refusing it would fence off something the wire format permits.
3227
2723
  */
3228
- function checkStepRefs(built, diags, reservedIds = []) {
2724
+ function checkStepRefs(built, diags) {
3229
2725
  const order = new Map();
3230
2726
  /** Step name → the `.stepToList` whose port path it lives in — a path only that port reaches. */
3231
2727
  const portListOf = new Map();
@@ -3337,56 +2833,10 @@ function checkStepRefs(built, diags, reservedIds = []) {
3337
2833
  // End, so its Ends count toward the duplicate-name rule below (#1231).
3338
2834
  for (const ep of built.entryPoints ?? [])
3339
2835
  index(ep.steps, undefined, '', undefined);
3340
- // Two `.return`s asking for the same End name with different content are two
3341
- // End nodes, which compile refuses rather than renaming the second `<name>2`
3342
- // (#1231). One End shared by several paths is spelled once, with every other
3343
- // path ending at it by `.stepToRef('<name>')`, or repeated identically.
3344
- for (const [name, count] of endNames) {
3345
- if (count < 2)
3346
- continue;
3347
- diags.push({
3348
- level: 'error', code: 'DUP_END_NAME', step: name,
3349
- message: `${count} different \`.return({}, { name: '${name}' })\` calls ask for the same End (their values, label, `
3350
- + `version or updates differ, or they sit in different scopes), so they would be ${count} End nodes with one id. `
3351
- + `To end several paths at one End, keep one \`.return({}, { name: '${name}' })\` and end the other paths with `
3352
- + `\`.stepToRef('${name}')\`; to have separate Ends, give each its own name.`,
3353
- });
3354
- }
3355
- // A named End's node id is its name with every character outside
3356
- // `[A-Za-z0-9_]` made `_`, as a step's is, and compile never renames it
3357
- // (#1253): it is an id the author chose, or a decompiled file's own. So an End
3358
- // whose name gives the id of another End, a step, the trigger or an entry
3359
- // point is refused here, and `serialize` throws for it under `--no-check`.
3360
- // Before, it was the End that moved (`.step('x').return({}, { name: 'x' })`
3361
- // compiled to End `x2`), or, emitted first, the step.
3362
- const safeId = (n) => n.replace(/[^A-Za-z0-9_]/g, '_') || 'node';
3363
- const stepById = new Map();
3364
- for (const n of stepNamesSeen)
3365
- if (!stepById.has(safeId(n)))
3366
- stepById.set(safeId(n), n);
3367
- const endById = new Map();
3368
- for (const name of endNames.keys()) {
3369
- const id = safeId(name);
3370
- const other = endById.get(id);
3371
- if (other !== undefined) {
3372
- diags.push({
3373
- level: 'error', code: 'DUP_END_NAME', step: name,
3374
- message: `Ends "${other}" and "${name}" would both get node id "${id}". Give them names that differ in `
3375
- + 'letters, digits or "_".',
3376
- });
3377
- continue;
3378
- }
3379
- endById.set(id, name);
3380
- const step = stepById.get(id);
3381
- if (step !== undefined || reservedIds.includes(id)) {
3382
- diags.push({
3383
- level: 'error', code: 'STEP_ID_COLLISION', step: name,
3384
- message: `End "${name}" (\`.return({}, { name: '${name}' })\`) would get node id "${id}", which `
3385
- + `${step !== undefined ? `step "${step}"` : 'the trigger or an entry point'} has. Compile will not rename `
3386
- + 'either one, so give the End a name no step or trigger uses.',
3387
- });
3388
- }
3389
- }
2836
+ // Two `.return`s asking for one End name with different content, and an End
2837
+ // whose name gives the node id of another End, a step, the trigger or an
2838
+ // entry point, are the serializer's DUP_END_NAME / STEP_ID_COLLISION: compile
2839
+ // never renames an End's id (#1231, #1253).
3390
2840
  /** Every ref, with where it sits. */
3391
2841
  const found = [];
3392
2842
  const collect = (steps, from, scope, arm, enclosing = [], paths = []) => {
@@ -3444,13 +2894,9 @@ function checkStepRefs(built, diags, reservedIds = []) {
3444
2894
  const say = (level, code, message, suggestion) => {
3445
2895
  diags.push({ level, code, message, step: from ?? 'stepToRef', ...(suggestion ? { suggestion } : {}) });
3446
2896
  };
3447
- if (!order.has(target)) {
3448
- const near = [...order.keys()].find((n) => n.toLowerCase() === target.toLowerCase());
3449
- say('error', 'REF_UNKNOWN_TARGET', `.stepToRef('${target}') ${where} names a step this flow does not have`
3450
- + `${near ? ` (did you mean "${near}"?)` : ''}. It takes the same name you passed to .step() or `
3451
- + `.terminate(), or the \`name\` of a \`.return({}, { name })\` End.`, near ? `.stepToRef('${near}')` : undefined);
2897
+ // A name the flow does not have is the serializer's REF_UNKNOWN_TARGET.
2898
+ if (!order.has(target))
3452
2899
  continue;
3453
- }
3454
2900
  const ends = endNames.get(target) ?? 0;
3455
2901
  // Two Ends sharing the name are DUP_END_NAME above, reported once whether or
3456
2902
  // not a ref names them; this is the step-and-End clash.
@@ -3620,11 +3066,8 @@ function checkSchemaVersion(built, ancestors, diags) {
3620
3066
  const declared = built.schemaVersion;
3621
3067
  if (declared === undefined)
3622
3068
  return;
3623
- const refusal = schemaVersionRefusal(FLOW_SCHEMA_VERSIONS, declared);
3624
- if (refusal) {
3625
- diags.push({ level: 'error', code: 'SCHEMA_VERSION_UNSUPPORTED', message: refusal });
3626
- return;
3627
- }
3069
+ // A version this build cannot write is the serializer's SCHEMA_VERSION_UNSUPPORTED
3070
+ // on the root flow. On a subflow it has no effect, whatever it is.
3628
3071
  if (ancestors.length > 0) {
3629
3072
  diags.push({
3630
3073
  level: 'warning',
@@ -3635,6 +3078,8 @@ function checkSchemaVersion(built, ancestors, diags) {
3635
3078
  });
3636
3079
  return;
3637
3080
  }
3081
+ if (schemaVersionRefusal(FLOW_SCHEMA_VERSIONS, declared))
3082
+ return;
3638
3083
  const floor = FLOW_SCHEMA_VERSIONS.default;
3639
3084
  const side = compareSchemaVersions(declared, floor);
3640
3085
  if (side > 0) {
@@ -3658,42 +3103,6 @@ function checkSchemaVersion(built, ancestors, diags) {
3658
3103
  });
3659
3104
  }
3660
3105
  }
3661
- /**
3662
- * Run a custom node's contribution lowering DRY, so `check` reports what
3663
- * `compile` would throw. For instance, a `rawNode` carrying an in-solution
3664
- * sibling's manifest with no `values` row for `<bindings.folderPath>` fails here
3665
- * with the same code compile throws.
3666
- *
3667
- * It calls `contribute()`, resolves the definition the way serialize does, and
3668
- * plans the `bindings[]` entries serialize would build
3669
- * ({@link planContributionBindings}). A name the plan cannot close is
3670
- * `CONTRIBUTION_BINDING_UNRESOLVED` — the same code and sentence serialize
3671
- * throws. A contribution that throws is `CONTRIBUTION_INVALID`, unless
3672
- * `ownError` says the node's own `diagnostics()` already reported an error (a
3673
- * raw connector node throws for the reason `RAW_NODE_CONNECTOR` already gives).
3674
- */
3675
- function checkContribution(node, requestedVersion, step, ownError, diags) {
3676
- let def;
3677
- let supplied;
3678
- try {
3679
- const contribution = node.contribute(requestedVersion !== undefined ? { requestedVersion } : {});
3680
- def = resolveContributionDefinition(contribution.definition, requestedVersion, `step "${step}"`);
3681
- supplied = contribution.bindings;
3682
- }
3683
- catch (e) {
3684
- if (ownError)
3685
- return;
3686
- diags.push({
3687
- level: 'error', code: 'CONTRIBUTION_INVALID', step,
3688
- message: `compile cannot lower step "${step}": ${e instanceof Error ? e.message : String(e)}`,
3689
- });
3690
- return;
3691
- }
3692
- const plan = planContributionBindings(def, supplied);
3693
- if (plan.problems.length === 0)
3694
- return;
3695
- diags.push({ level: 'error', code: 'CONTRIBUTION_BINDING_UNRESOLVED', step, message: plan.problems.join(' ') });
3696
- }
3697
3106
  /**
3698
3107
  * The one step every arm of a `.parallel` ends by referring to, when the arms
3699
3108
  * join there instead of on a Merge: each arm ends in `.stepToRef(<that step>)`