@uipath/maestro-builder-sdk 6.60.0 → 6.61.1

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 (52) hide show
  1. package/README.md +10 -12
  2. package/dist/api-index.md +17 -16
  3. package/dist/api-members.md +48 -48
  4. package/dist/check-cli.js +8 -7
  5. package/dist/check.d.ts +11 -0
  6. package/dist/check.js +192 -783
  7. package/dist/cli-library.d.ts +2 -1
  8. package/dist/cli-library.js +7 -2
  9. package/dist/cli-result.d.ts +14 -0
  10. package/dist/cli-result.js +18 -1
  11. package/dist/compile-cli.js +2 -2
  12. package/dist/core/compile-refusal.d.ts +7 -1
  13. package/dist/core/compile-refusal.js +9 -1
  14. package/dist/core/connector-checks.d.ts +8 -30
  15. package/dist/core/connector-checks.js +11 -60
  16. package/dist/core/connector-inputs.js +9 -1
  17. package/dist/core/event-checks.d.ts +15 -2
  18. package/dist/core/event-checks.js +28 -13
  19. package/dist/core/hitl-routing.d.ts +10 -1
  20. package/dist/core/hitl-routing.js +20 -5
  21. package/dist/core/lookups.d.ts +13 -0
  22. package/dist/core/lookups.js +26 -0
  23. package/dist/core/refusals.d.ts +3 -0
  24. package/dist/core/refusals.js +6 -1
  25. package/dist/flow-node-type.d.ts +35 -21
  26. package/dist/flow-parallel-hitl.d.ts +105 -0
  27. package/dist/flow-parallel-hitl.js +214 -0
  28. package/dist/flow-sdk.d.ts +4 -4
  29. package/dist/flow-sdk.js +2 -2
  30. package/dist/flow-terminate-names.d.ts +2 -2
  31. package/dist/flow-terminate-names.js +2 -2
  32. package/dist/merge-compile.js +2 -1
  33. package/dist/nodes/agent/flow/check.js +13 -88
  34. package/dist/nodes/agent/flow/inline-agent.js +41 -14
  35. package/dist/nodes/connector/flow/node-type.js +45 -18
  36. package/dist/nodes/document/flow/ixp.d.ts +1 -1
  37. package/dist/nodes/document/flow/ixp.js +22 -26
  38. package/dist/nodes/hitl/flow/check.js +22 -13
  39. package/dist/nodes/hitl/flow/node-type.js +16 -7
  40. package/dist/nodes/hitl/flow/spec.d.ts +11 -5
  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/transform/flow/check.d.ts +2 -0
  47. package/dist/nodes/transform/flow/check.js +3 -6
  48. package/dist/nodes/transform/flow/node-type.js +6 -6
  49. package/dist/raw-node.js +8 -8
  50. package/dist/serialize.d.ts +38 -3
  51. package/dist/serialize.js +757 -322
  52. package/package.json +2 -2
package/dist/serialize.js CHANGED
@@ -12,11 +12,13 @@ import { Expr, toExpr, SCHEDULE_PRESETS, flatNameSegmentsOf, parallelFork } from
12
12
  import { FlowAction, FlowTrigger } from './core/node-classes.js';
13
13
  import { connectorRawNodeRefusal } from './core/connector-raw-node.js';
14
14
  import { CompileRefusal } from './core/compile-refusal.js';
15
+ import { nearestName } from './core/edit-distance.js';
15
16
  import { rawNodeInputProblem, registeredRawType } from './raw-node-advice.js';
16
17
  import { planContributionBindings } from './core/contribution-bindings.js';
17
18
  import { declaredErrorPortRefusal, definitionHasErrorPort, errorPortRefusal } from './core/error-port.js';
18
19
  import { FLOW_NODE_TYPES } from './flow-node-registry.js';
19
20
  import { stepExits } from './flow-step-exits.js';
21
+ import { endOutcomeRefusal, fanInRefusal, hitlEndHazards, hitlFanInto } from './flow-parallel-hitl.js';
20
22
  import { resolveVersionCarried } from './core/nodes/versioned.js';
21
23
  import { indexCarriedDefinitions } from './core/carried-definitions.js';
22
24
  import { connectionConnectors } from './bindings-v2.js';
@@ -30,7 +32,7 @@ import { hasExits } from './core/step-ports.js';
30
32
  import { stopsAtEnd } from './flow-step-tree.js';
31
33
  import { assertNodeEnvelope } from './core/node-envelope.js';
32
34
  import { fnv1aX4Uuid, stableId } from './core/stable-id.js';
33
- import { readEventFilter, eventFilterProblem, eventFilterJmes, eventFilterTreeLeaf, jmesString, jmesStringProblem } from './core/event-filters.js';
35
+ import { EVENT_FILTER_OPERATORS, readEventFilter, eventFilterProblem, eventFilterJmes, eventFilterTreeLeaf, jmesString, jmesStringProblem } from './core/event-filters.js';
34
36
  import { FLOW_EXPR_DIALECT } from './core/connector-checks.js';
35
37
  import { connectorInputErrors as coreConnectorInputErrors } from './core/connector-inputs.js';
36
38
  /**
@@ -532,8 +534,7 @@ function refuseUnverifiedByoa(opts, where, sub) {
532
534
  * INLINE_AGENT_TOOL_CONNECTOR_NO_CONNECTION, and serialize leaves it unbound.)
533
535
  * `resolveBinding` returns `undefined` for an absent label, which used to reach
534
536
  * the node as `detail.connectionId: null` with no binding — valid to compile,
535
- * refused by `flow validate` (flow-builder-sdk#890). `check` reports the same
536
- * sentence first (BINDING_MISSING).
537
+ * refused by `flow validate` (flow-builder-sdk#890): BINDING_MISSING.
537
538
  */
538
539
  function requireBindingPair(where, labels, target) {
539
540
  const missing = missingBindingLabels(labels);
@@ -709,6 +710,71 @@ export function resolveContributionDefinition(ref, requestedVersion, where) {
709
710
  }
710
711
  throw new CompileRefusal('CONTRIBUTION_INVALID', `${where}: unknown DefinitionReference source "${ref.source}".`);
711
712
  }
713
+ /**
714
+ * `library.resolveEvent` for one subscription, or EVENT_UNKNOWN: whatever it
715
+ * throws (a connector or event the library does not carry, a version or object
716
+ * it does not resolve) is that one condition, an event compile cannot place.
717
+ * `where` names the site as `check` always has (`trigger`, `waitForEvent "x"`).
718
+ */
719
+ function resolveEvent(library, kind, sub, where) {
720
+ try {
721
+ return library.resolveEvent(kind, sub.connector, sub.event, sub.version, sub.object);
722
+ }
723
+ catch (error) {
724
+ if (error instanceof Error && error.message.startsWith('internal SDK error:'))
725
+ throw error;
726
+ throw new CompileRefusal('EVENT_UNKNOWN', `${where}: ${error instanceof Error ? error.message : String(error)}`);
727
+ }
728
+ }
729
+ /**
730
+ * LIBRARY_REQUIRED for an event subscription at `site`, after the refusals that
731
+ * need no library: its binding labels, judged as `eventDetail` judges them, so a
732
+ * run without a library (`check`'s inner loop) still hears about them.
733
+ */
734
+ function libraryRequiredForEvent(opts, sub, nodeId, site, call) {
735
+ if (sub !== null && typeof sub === 'object') {
736
+ if (Array.isArray(sub.filters))
737
+ eventFilterReads(sub.filters, nodeId);
738
+ requireBindingPair(site, sub, eventTarget(sub));
739
+ resolveBinding(opts, sub.connection, 'connection', `event node "${nodeId}"`, eventTarget(sub));
740
+ resolveBinding(opts, sub.folder, 'folder', `event node "${nodeId}"`, eventTarget(sub));
741
+ }
742
+ return new CompileRefusal('LIBRARY_REQUIRED', `${call} needs a library (pass --library / set $FLOW_SDK_LIBRARY_JSON).`);
743
+ }
744
+ /** The trigger kinds a `.trigger()` or `.entryPoint()` spec may carry. */
745
+ const TRIGGER_KINDS = new Set(['manual', 'scheduled', 'event', 'form', 'conversation', 'voice']);
746
+ /**
747
+ * Refuse a trigger spec that is not one: types are stripped at run time, so
748
+ * `.trigger({ every: 'R/PT1H' })` (the spec without its factory) or a typo'd
749
+ * kind arrives intact. `call` is how the author wrote the site (`.trigger()`,
750
+ * `.entryPoint("ep")`).
751
+ */
752
+ function refuseUnknownTrigger(spec, call) {
753
+ const kind = spec?.kind;
754
+ if (typeof kind === 'string' && TRIGGER_KINDS.has(kind))
755
+ return;
756
+ throw new CompileRefusal('TRIGGER_UNKNOWN_KIND', `${call} was given ${JSON.stringify(kind)}, which is not a trigger. `
757
+ + `Wrap it in a factory: scheduled({ every: 'R/PT1H' }) for a schedule, `
758
+ + `onEvent({ connector, event, where, connection, folder }) to start on a connector event, formTrigger() to start from a `
759
+ + `submitted form, or conversationTrigger() to start on a new conversation. Omit .trigger() entirely `
760
+ + `for the manual trigger (the default).`, `scheduled({ every: 'R/PT1H' })`);
761
+ }
762
+ /**
763
+ * A custom node's contribution. A `contribute()` that throws something other
764
+ * than a refusal (an author's own `Error`) is the node's refusal,
765
+ * CONTRIBUTION_INVALID, so `check` reports it on the node and `compile` names
766
+ * the code. An `internal SDK error:` is passed on as it is.
767
+ */
768
+ function contributionOf(node, requestedVersion, where) {
769
+ try {
770
+ return node.contribute(requestedVersion !== undefined ? { requestedVersion } : {});
771
+ }
772
+ catch (error) {
773
+ if (error instanceof CompileRefusal || (error instanceof Error && error.message.startsWith('internal SDK error:')))
774
+ throw error;
775
+ throw new CompileRefusal('CONTRIBUTION_INVALID', `compile cannot lower ${where}: ${error instanceof Error ? error.message : String(error)}`);
776
+ }
777
+ }
712
778
  /**
713
779
  * Whether `def` declares `port` as a forward source handle: an exact id, or a
714
780
  * templated one (`outcome-{item.id}` declares every `outcome-<slug>`). `error` is
@@ -806,6 +872,43 @@ function flattenWhere(where, declared) {
806
872
  walk(where, '');
807
873
  return out;
808
874
  }
875
+ /**
876
+ * A subscription's `filters`, each leaf read structurally (`readEventFilter`),
877
+ * or the refusal of the first leaf compile cannot lower: no field, no operator
878
+ * or two, a value no operator takes, a backslash JMESPath engines read apart.
879
+ * Needs no library, so a run without one judges the leaves too
880
+ * (`libraryRequiredForEvent`).
881
+ */
882
+ function eventFilterReads(filters, nodeId) {
883
+ const operators = Object.keys(EVENT_FILTER_OPERATORS).join(', ');
884
+ const at = `event node "${nodeId}": filters`;
885
+ return filters.map((f, i) => {
886
+ const { keys, read } = readEventFilter(f);
887
+ const field = f?.field;
888
+ if (typeof field !== 'string' || field === '') {
889
+ throw new CompileRefusal('EVENT_FILTER_NO_FIELD', `${at}[${i}] has no \`field\` — name a field on the event payload, e.g. 'subject'.`);
890
+ }
891
+ if (keys.length === 0) {
892
+ throw new CompileRefusal('EVENT_FILTER_NO_VALUE', `${at}[${i}] has no operator. Write exactly one of ${operators} with its value — `
893
+ + `\`contains\` is a case-sensitive substring test, the same one Integration Service applies `
894
+ + `server-side; \`lessThan\`/\`greaterThan\` order a number or an ISO date.`, `{ field: '${field}', contains: '<text>' }`);
895
+ }
896
+ if (keys.length > 1) {
897
+ throw new CompileRefusal('EVENT_FILTER_MANY_OPERATORS', `${at}[${i}] carries ${keys.length} operators (${keys.join(', ')}); a condition has exactly `
898
+ + `one. Write two conditions instead — they are ANDed.`);
899
+ }
900
+ if (!read) {
901
+ throw new CompileRefusal('EVENT_FILTER_NO_VALUE', `${at}[${i}] gives \`${keys[0]}\` a value that is not a string, number or boolean.`);
902
+ }
903
+ // An ambiguous backslash first: `eventFilterProblem` would report it too.
904
+ const problem = eventFilterProblem(read);
905
+ const backslash = typeof read.value === 'string' && jmesStringProblem(read.value) !== undefined;
906
+ if (problem) {
907
+ throw new CompileRefusal(backslash ? 'EVENT_FILTER_BACKSLASH' : 'EVENT_FILTER_BAD_VALUE', `${at}[${i}] on \`${read.field}\`: ${problem}`);
908
+ }
909
+ return read;
910
+ });
911
+ }
809
912
  function eventDetail(rc, sub, nodeId, opts, site) {
810
913
  requireBindingPair(site, sub, eventTarget(sub));
811
914
  const conn = resolveBinding(opts, sub.connection, 'connection', `event node "${nodeId}"`, eventTarget(sub));
@@ -817,8 +920,13 @@ function eventDetail(rc, sub, nodeId, opts, site) {
817
920
  // would otherwise emit it, with the text every family's compile shares.
818
921
  const objectName = String(op.objectName ?? '');
819
922
  const noObject = genericEventObjectRefusal(rc, sub, (o) => `onEvent(…, { object: '${o}' })`);
820
- if (noObject)
821
- throw new CompileRefusal('EVENT_GENERIC_NO_OBJECT', `event node "${nodeId}": ${noObject}`);
923
+ if (noObject) {
924
+ // The object an author put in `where` by mistake is the value to move.
925
+ const scopeObject = sub.where && typeof sub.where === 'object'
926
+ ? Object.entries(sub.where).find(([k]) => /^(objectName|entityName|object)$/i.test(k))?.[1]
927
+ : undefined;
928
+ throw new CompileRefusal('EVENT_GENERIC_NO_OBJECT', `event node "${nodeId}": ${noObject}`, `object: '${scopeObject !== undefined ? String(scopeObject) : '<Object>'}'`);
929
+ }
822
930
  // An unresolved `where` lookup is swapped for its recorded id — the id
823
931
  // `prepare --resolve` stored under the EVENT-form node type — or refused.
824
932
  // Same rule as connector inputs: emitting a placeholder for a value nobody
@@ -871,27 +979,8 @@ function eventDetail(rc, sub, nodeId, opts, site) {
871
979
  });
872
980
  // Each authored filter is read structurally (which operator, which value) and
873
981
  // lowered with the designer's own grammar — see `event-filters.ts` for the
874
- // measured table. `check` has already refused a malformed leaf; a leaf that
875
- // still cannot be read here is an author error, not something to drop silently.
876
- const conditions = filters.map((f, i) => {
877
- const { keys, read } = readEventFilter(f);
878
- if (!read) {
879
- // The code `check` gives the same leaf (`core/event-checks.ts`).
880
- const field = f?.field;
881
- throw new CompileRefusal(typeof field !== 'string' || field === '' ? 'EVENT_FILTER_NO_FIELD'
882
- : keys.length > 1 ? 'EVENT_FILTER_MANY_OPERATORS' : 'EVENT_FILTER_NO_VALUE', `event node "${nodeId}": filters[${i}] must carry a field and exactly ONE operator `
883
- + `(contains, startsWith, endsWith, equals, notEquals, lessThan, lessThanOrEqual, greaterThan, `
884
- + `greaterThanOrEqual) — got ${JSON.stringify(f)}.`);
885
- }
886
- // An ambiguous backslash first, under the code `check` gives it
887
- // (`core/event-checks.ts`); `eventFilterProblem` would report it too.
888
- const problem = eventFilterProblem(read);
889
- const backslash = typeof read.value === 'string' && jmesStringProblem(read.value) !== undefined;
890
- if (problem) {
891
- throw new CompileRefusal(backslash ? 'EVENT_FILTER_BACKSLASH' : 'EVENT_FILTER_BAD_VALUE', `event node "${nodeId}": filters[${i}] on \`${read.field}\`: ${problem}`);
892
- }
893
- return read;
894
- });
982
+ // measured table.
983
+ const conditions = eventFilterReads(filters, nodeId);
895
984
  const filterClauses = conditions.map((c) => `(${eventFilterJmes(c)})`);
896
985
  const filterExpression = [
897
986
  ...scopeClauses,
@@ -1032,10 +1121,10 @@ function misplacedLookupMessage(token) {
1032
1121
  /**
1033
1122
  * The flow's declarations as the globals it will emit: a same-named `.output()`
1034
1123
  * and `.var()` folded into one `out` global (flow-builder-sdk#899), every other
1035
- * repeated name refused. `check()` reports the same problems as
1036
- * VARIABLE_NAME_COLLISION / OUTPUT_VAR_MISMATCH; this throws too, so a path that skips the static pass
1037
- * (`compile --no-check`, `emitFlow`, a direct `serialize()`) cannot write a
1038
- * file `flow validate` rejects for a duplicate variable id.
1124
+ * repeated name refused (VARIABLE_NAME_COLLISION / OUTPUT_VAR_MISMATCH), so no
1125
+ * path (`compile`, `emitFlow`, a direct `serialize()`) can write a file
1126
+ * `flow validate` rejects for a duplicate variable id; `check` reports the
1127
+ * refusal from its serializer run.
1039
1128
  */
1040
1129
  function emittableGlobals(built, where, kind = 'root') {
1041
1130
  // Entry-point inputs are rows of the same globals[], so they share the
@@ -1046,21 +1135,21 @@ function emittableGlobals(built, where, kind = 'root') {
1046
1135
  const shared = sharedInputsOf(built, where, kind);
1047
1136
  const plan = planFlowGlobals({ inputs, outputs: built.outputs, vars: [...built.vars, ...shared.autoVars] });
1048
1137
  if (plan.problems.length) {
1049
- throw new CompileRefusal(plan.problems[0].code, `${where}: ${plan.problems.map((p) => `[${p.code}] ${p.message}`).join(' ')}`);
1138
+ throw new CompileRefusal(plan.problems[0].code, `${where}: ${plan.problems.map((p) => `[${p.code}] ${p.message}`).join(' ')}`, plan.problems[0].suggestion);
1050
1139
  }
1051
1140
  // An output the flow sets from inside gets an `inout` Variable to hold the
1052
1141
  // value; the End copies it into the Output (#1092, `flow-output-backing.ts`).
1053
1142
  const sharedOutputs = new Set(shared.targets.filter((t) => t.declaredAs === 'output').map((t) => t.name));
1054
1143
  const backing = planOutputBacking(built, plan.outputs, plan.vars, sharedOutputs);
1055
1144
  if (backing.problems.length) {
1056
- throw new CompileRefusal(backing.problems[0].code, `${where}: ${backing.problems.map((p) => `[${p.code}] ${p.message}`).join(' ')}`);
1145
+ throw new CompileRefusal(backing.problems[0].code, `${where}: ${backing.problems.map((p) => `[${p.code}] ${p.message}`).join(' ')}`, backing.problems[0].suggestion);
1057
1146
  }
1058
1147
  return { ...plan, backing: backing.backing, backingReads: backing.reads };
1059
1148
  }
1060
1149
  /**
1061
- * The flow's `{ shared }` input plan (#924), refused here as `check` refuses it
1062
- * — so `compile --no-check` cannot write what `check` would have stopped.
1063
- * Warnings (a root that feeds nothing) are `check`'s alone.
1150
+ * The flow's `{ shared }` input plan (#924), its errors refused here (and
1151
+ * reported by `check` from its serializer run). Warnings (a root that feeds
1152
+ * nothing) are `check`'s alone.
1064
1153
  */
1065
1154
  function sharedInputsOf(built, where, kind) {
1066
1155
  if (kind === 'subflow') {
@@ -1072,8 +1161,9 @@ function sharedInputsOf(built, where, kind) {
1072
1161
  }
1073
1162
  const plan = planSharedInputs({ roots: sharedInputRoots(built), outputs: built.outputs, vars: built.vars });
1074
1163
  const errors = plan.problems.filter((p) => p.level === 'error');
1075
- if (errors.length)
1076
- throw new CompileRefusal(errors[0].code, `${where}: ${errors.map((p) => `[${p.code}] ${p.message}`).join(' ')}`);
1164
+ if (errors.length) {
1165
+ throw new CompileRefusal(errors[0].code, `${where}: ${errors.map((p) => `[${p.code}] ${p.message}`).join(' ')}`, errors[0].suggestion);
1166
+ }
1077
1167
  return plan;
1078
1168
  }
1079
1169
  function newScope(outputs, vars = [], backing = new Map(), backingReads = backing) {
@@ -1147,14 +1237,22 @@ function newScope(outputs, vars = [], backing = new Map(), backingReads = backin
1147
1237
  * exact class of failure `.onError` used to have (an error path that vanishes in
1148
1238
  * conversion with every rung green). So it throws instead.
1149
1239
  */
1150
- function resolveRejoins(scope, addEdge) {
1240
+ function resolveRejoins(scope, addEdge,
1241
+ /** Refuse an edge whose target is unknown: throws, or (collect mode) reports it on the step it leaves and returns. */
1242
+ refuse = (_from, refusal) => { throw refusal; }) {
1151
1243
  for (const { from, target } of scope.pendingEdges) {
1152
1244
  const id = scope.stepNodeId.get(target) ?? scope.endNodeId.get(target);
1153
1245
  if (!id) {
1246
+ const steps = [...scope.stepNodeId.keys()];
1154
1247
  const ends = [...scope.endNodeId.keys()];
1155
- throw new CompileRefusal('REF_UNKNOWN_TARGET', `.stepToRef('${target}') names a step this flow does not have. Known steps: `
1156
- + `${[...scope.stepNodeId.keys()].join(', ') || '(none)'}`
1157
- + `${ends.length ? `; named Ends: ${ends.join(', ')}` : ''}.`);
1248
+ const near = [...steps, ...ends].find((n) => n.toLowerCase() === target.toLowerCase());
1249
+ const source = [...scope.stepNodeId].find(([, nodeId]) => nodeId === from.node)?.[0];
1250
+ refuse(from, new CompileRefusal('REF_UNKNOWN_TARGET', `.stepToRef('${target}') ${source !== undefined ? `after "${source}" ` : ''}names a step this flow does not have`
1251
+ + `${near ? ` (did you mean "${near}"?)` : ''}. It takes the same name you passed to .step() or `
1252
+ + `.terminate(), or the \`name\` of a \`.return({}, { name })\` End. Known steps: `
1253
+ + `${steps.join(', ') || '(none)'}`
1254
+ + `${ends.length ? `; named Ends: ${ends.join(', ')}` : ''}.`, near ? `.stepToRef('${near}')` : undefined));
1255
+ continue;
1158
1256
  }
1159
1257
  // Through the serializer's own `addEdge`, so the id comes from the one
1160
1258
  // file-wide counter. A per-scope count (`edge_ref_${scope.edges.length}`)
@@ -1425,7 +1523,7 @@ function renameRefs(text, rename) {
1425
1523
  * port paths (`.stepToList`, `.onError`) included: their steps are nodes of
1426
1524
  * this scope too, so they need an id claimed with the rest.
1427
1525
  */
1428
- function stepNamesOf(steps, out = [], terminates) {
1526
+ function stepNamesOf(steps, out = [], terminates, forks) {
1429
1527
  for (const s of steps) {
1430
1528
  // `return` carries a synthetic `name` but creates no node named after it (an
1431
1529
  // End id is claimed as `end`); `stepToRef` has no `name` at all (it is one edge).
@@ -1434,30 +1532,32 @@ function stepNamesOf(steps, out = [], terminates) {
1434
1532
  // A `.parallel` with a fork node claims that node's name too (#1310).
1435
1533
  if (s.kind === 'parallel') {
1436
1534
  const fork = parallelFork(s.options);
1437
- if (fork)
1535
+ if (fork) {
1438
1536
  out.push(fork.name);
1537
+ forks?.set(fork.name, s.name);
1538
+ }
1439
1539
  }
1440
1540
  // How many `.terminate`s hold each name, so a clash gets the right remedy (#1239).
1441
1541
  if (s.kind === 'terminate' && terminates)
1442
1542
  terminates.set(s.name, (terminates.get(s.name) ?? 0) + 1);
1443
1543
  if (s.kind === 'stepToList')
1444
- stepNamesOf(s.body, out, terminates);
1544
+ stepNamesOf(s.body, out, terminates, forks);
1445
1545
  if (s.kind === 'branch') {
1446
- stepNamesOf(s.then, out, terminates);
1447
- stepNamesOf(s.otherwise, out, terminates);
1546
+ stepNamesOf(s.then, out, terminates, forks);
1547
+ stepNamesOf(s.otherwise, out, terminates, forks);
1448
1548
  }
1449
1549
  else if (s.kind === 'switch') {
1450
- s.cases.forEach((c) => stepNamesOf(c.body, out, terminates));
1451
- stepNamesOf(s.default ?? [], out, terminates);
1550
+ s.cases.forEach((c) => stepNamesOf(c.body, out, terminates, forks));
1551
+ stepNamesOf(s.default ?? [], out, terminates, forks);
1452
1552
  }
1453
1553
  else if (s.kind === 'stepSwitch')
1454
- s.cases.forEach((c) => stepNamesOf(c.body, out, terminates));
1554
+ s.cases.forEach((c) => stepNamesOf(c.body, out, terminates, forks));
1455
1555
  else if (s.kind === 'parallel')
1456
- s.arms.forEach((a) => stepNamesOf(a, out, terminates));
1556
+ s.arms.forEach((a) => stepNamesOf(a, out, terminates, forks));
1457
1557
  else if (s.kind === 'loop')
1458
- stepNamesOf(s.body, out, terminates);
1558
+ stepNamesOf(s.body, out, terminates, forks);
1459
1559
  else if (s.kind === 'doWhile')
1460
- stepNamesOf(s.body, out, terminates);
1560
+ stepNamesOf(s.body, out, terminates, forks);
1461
1561
  }
1462
1562
  return out;
1463
1563
  }
@@ -1515,6 +1615,73 @@ function safeNodeId(name) {
1515
1615
  return name.replace(/[^A-Za-z0-9_]/g, '_') || 'node';
1516
1616
  }
1517
1617
  export function serialize(built, opts = {}) {
1618
+ const collect = opts.collect;
1619
+ if (!collect)
1620
+ return serializeFlow(built, opts);
1621
+ try {
1622
+ return serializeFlow(built, opts);
1623
+ }
1624
+ catch (error) {
1625
+ // A step's own refusal was taken by its catch (`emitStep`), so one that gets
1626
+ // here was thrown outside any one step: scope-level, and collection stops.
1627
+ // A throw that is no refusal is compile's COMPILE_FAILED; an internal SDK
1628
+ // error is passed on.
1629
+ if (!(error instanceof Error) || (!(error instanceof CompileRefusal) && error.message.startsWith('internal SDK error:')))
1630
+ throw error;
1631
+ const refusal = error instanceof CompileRefusal ? error : new CompileRefusal('COMPILE_FAILED', error.message);
1632
+ collect({ code: refusal.code, message: refusal.message, ...(refusal.suggestion !== undefined ? { suggestion: refusal.suggestion } : {}) });
1633
+ return undefined;
1634
+ }
1635
+ }
1636
+ /**
1637
+ * The value an author wrote, as plain JSON: an expression as its `=js:` source,
1638
+ * a class instance as its own fields, a function dropped. Deterministic, so two
1639
+ * compiles of one unchanged spec give equal stand-ins (D5).
1640
+ */
1641
+ function authoredValue(value, path = []) {
1642
+ if (value instanceof Expr)
1643
+ return `=js:${value.js}`;
1644
+ if (typeof value === 'function' || typeof value === 'symbol')
1645
+ return undefined;
1646
+ if (typeof value === 'bigint')
1647
+ return String(value);
1648
+ if (value === null || typeof value !== 'object')
1649
+ return value;
1650
+ if (path.includes(value))
1651
+ return '[circular]';
1652
+ const inner = [...path, value];
1653
+ if (Array.isArray(value))
1654
+ return value.map((v) => authoredValue(v, inner) ?? null);
1655
+ const out = {};
1656
+ for (const [k, v] of Object.entries(value)) {
1657
+ const a = authoredValue(v, inner);
1658
+ if (a !== undefined)
1659
+ out[k] = a;
1660
+ }
1661
+ return out;
1662
+ }
1663
+ const VERSION_RE = /^\d+\.\d+(\.\d+)?$/;
1664
+ /**
1665
+ * The opaque node collect mode emits for a step (or a root's trigger) whose
1666
+ * emission was refused: the node type and the spec as authored, under its node
1667
+ * id, with no definition (docs/CHECK_COMPILE_CONTRACT_DESIGN.md D5). A
1668
+ * comparison of two compiles finds it equal exactly when the spec, version and
1669
+ * name are.
1670
+ */
1671
+ function standInNode(name, authored, version, id, parentId) {
1672
+ const spec = (authored ?? {});
1673
+ const kind = typeof spec.kind === 'string' ? spec.kind : undefined;
1674
+ const registered = kind !== undefined ? FLOW_NODE_TYPES.byKind(kind, spec) : undefined;
1675
+ return {
1676
+ id,
1677
+ type: registered?.type.typeName ?? bundledActionNodeType(spec) ?? kind ?? 'action',
1678
+ typeVersion: version !== undefined && VERSION_RE.test(version) ? version : '1.0.0',
1679
+ ...(parentId ? { parentId } : {}),
1680
+ display: { label: name },
1681
+ inputs: { standIn: true, spec: authoredValue(spec) },
1682
+ };
1683
+ }
1684
+ function serializeFlow(built, opts) {
1518
1685
  /**
1519
1686
  * Definitions the flow carries (`.definitions()`, #1128), by `type@version` —
1520
1687
  * the third source a pinned `{ version }` resolves from, after the bundle and
@@ -1537,8 +1704,9 @@ export function serialize(built, opts = {}) {
1537
1704
  */
1538
1705
  const emittedDefs = new Map();
1539
1706
  /**
1540
- * The nodes a draft compile emitted unbound (their `BINDING_MISSING` finding):
1541
- * they write no binding rows by design, so the placeholder check exempts them.
1707
+ * The nodes a draft compile emitted unbound (their `BINDING_MISSING` finding),
1708
+ * and collect mode's stand-ins: they write no binding rows by design, so the
1709
+ * placeholder check exempts them.
1542
1710
  */
1543
1711
  const unboundDrafts = new Set();
1544
1712
  /**
@@ -1572,16 +1740,18 @@ export function serialize(built, opts = {}) {
1572
1740
  /**
1573
1741
  * Refuse an `error` edge from a node whose definition has no error port: a
1574
1742
  * contribution-lowered node (#943) or a typed node with a bundled definition
1575
- * (#1011). Same predicate as `check`, so `compile --no-check` agrees with it.
1743
+ * (#1011): ONERROR_NO_ERROR_PORT, which `check` reports from its serializer run.
1576
1744
  */
1577
- function refuseErrorEdgeWithoutPort(nodeId, stepName) {
1745
+ function refuseErrorEdgeWithoutPort(scope, nodeId, stepName) {
1578
1746
  const declared = declaredErrorPorts.get(nodeId);
1579
- if (declared?.port === false)
1580
- throw new CompileRefusal('ONERROR_NO_ERROR_PORT', declaredErrorPortRefusal(stepName, declared.typeName, declared.version));
1747
+ if (declared?.port === false) {
1748
+ refuseOn(scope, stepName, new CompileRefusal('ONERROR_NO_ERROR_PORT', declaredErrorPortRefusal(stepName, declared.typeName, declared.version)));
1749
+ return;
1750
+ }
1581
1751
  const contributed = contributionDefs.get(nodeId);
1582
1752
  const def = contributed ?? bundledNodeDefs.get(nodeId);
1583
1753
  if (def !== undefined && !definitionHasErrorPort(def)) {
1584
- throw new CompileRefusal('ONERROR_NO_ERROR_PORT', errorPortRefusal(stepName, def, { bundled: contributed === undefined }));
1754
+ refuseOn(scope, stepName, new CompileRefusal('ONERROR_NO_ERROR_PORT', errorPortRefusal(stepName, def, { bundled: contributed === undefined })));
1585
1755
  }
1586
1756
  }
1587
1757
  /**
@@ -1645,24 +1815,29 @@ export function serialize(built, opts = {}) {
1645
1815
  * gets them and one that does not is unaffected.
1646
1816
  */
1647
1817
  const sidecars = opts.sidecars ?? [];
1648
- /** The sidecar paths a resource's explicit `source` chose. */
1649
- const explicitSidecars = new Set();
1818
+ /** The sidecar paths an explicit `source` chose → who chose it (see `addSidecar`). */
1819
+ const explicitSidecars = new Map();
1650
1820
  /**
1651
- * Add one sidecar file. A resource whose explicit `source` names a directory
1652
- * another resource also writes would have the second file silently replace
1653
- * the first: index A's node would read B's settings. That is refused; `check`
1654
- * reports it first (INLINE_AGENT_RESOURCE_SOURCE_DUP). The same file twice (a
1655
- * subflow body emitted for two callers) is not a clash.
1821
+ * Add one sidecar file. An agent or a resource whose explicit `source` names
1822
+ * a directory another one also writes would have the second file silently
1823
+ * replace the first: index A's node would read B's settings, or agent B would
1824
+ * run with A's configuration. That is refused (INLINE_AGENT_RESOURCE_SOURCE_DUP),
1825
+ * naming both; `owner` is the one whose `source` is explicit, as the refusal
1826
+ * names it (`inline agent step "answer"'s context index "SOP"`). The same file
1827
+ * twice (a subflow body emitted for two callers) is not a clash.
1656
1828
  */
1657
- const addSidecar = (file, explicitSource = false) => {
1829
+ const addSidecar = (file, owner) => {
1658
1830
  const earlier = sidecars.find((f) => f.path === file.path);
1831
+ const earlierOwner = explicitSidecars.get(file.path);
1659
1832
  if (earlier !== undefined && JSON.stringify(earlier.json) !== JSON.stringify(file.json)
1660
- && (explicitSource || explicitSidecars.has(file.path))) {
1661
- throw new CompileRefusal('INLINE_AGENT_RESOURCE_SOURCE_DUP', `two resources would both write "${file.path}" with different content, so the second would replace the first. `
1662
- + 'Give each resource its own `source`, or omit `source` to derive one.');
1663
- }
1664
- if (explicitSource)
1665
- explicitSidecars.add(file.path);
1833
+ && (owner !== undefined || earlierOwner !== undefined)) {
1834
+ const names = [earlierOwner ?? 'another agent or resource', owner ?? 'another agent or resource'];
1835
+ throw new CompileRefusal('INLINE_AGENT_RESOURCE_SOURCE_DUP', `${names[0].charAt(0).toUpperCase()}${names[0].slice(1)} and ${names[1]} would both write "${file.path}" with `
1836
+ + 'different content, so the second file would replace the first. Give each its own `source`, or omit '
1837
+ + '`source` to derive one.');
1838
+ }
1839
+ if (owner !== undefined && earlierOwner === undefined)
1840
+ explicitSidecars.set(file.path, owner);
1666
1841
  sidecars.push(file);
1667
1842
  };
1668
1843
  /**
@@ -1677,6 +1852,10 @@ export function serialize(built, opts = {}) {
1677
1852
  return opts.agentSource ? opts.agentSource(source, stepName) : source;
1678
1853
  };
1679
1854
  const subflows = {};
1855
+ /** The calling steps of the subflow bodies being emitted, outermost first (`CollectedRefusal.subflows`). */
1856
+ const subflowPath = [];
1857
+ /** A refusal that escaped a subflow body in collect mode: scope-level, so no step's catch takes it. */
1858
+ let scopeRefusal;
1680
1859
  const rootGlobals = emittableGlobals(built, `flow "${built.id}"`);
1681
1860
  const root = newScope(rootGlobals.outputs, rootGlobals.vars, rootGlobals.backing, rootGlobals.backingReads);
1682
1861
  root.openTails = built.openTails === true;
@@ -1737,15 +1916,15 @@ export function serialize(built, opts = {}) {
1737
1916
  * End `x2`, and an unnamed `.return()` emitted first turned a named `end`
1738
1917
  * into `end2`. Now:
1739
1918
  *
1740
- * - two Ends whose names give one id are refused (`check`: DUP_END_NAME);
1919
+ * - two Ends whose names give one id are refused (DUP_END_NAME);
1741
1920
  * - an End whose name gives a step's id, the trigger's or an entry point's
1742
- * is refused (`check`: STEP_ID_COLLISION);
1921
+ * is refused (STEP_ID_COLLISION);
1743
1922
  * - every other id is claimed here, so the ids the SDK makes up (`end`,
1744
1923
  * `end2`, an agent's context node, ...) are the ones that move.
1745
1924
  *
1746
- * `check` reports these first; this is the backstop for `compile --no-check`,
1747
- * the draft-tolerant merge compile and a direct `serialize()`. A subflow COPY
1748
- * is the one place ids move: `copyPrefix` is set when the copy is prefixed.
1925
+ * These are scope-level refusals: `check` reports the first, with no step.
1926
+ * A subflow COPY is the one place ids move: `copyPrefix` is set when the
1927
+ * copy is prefixed.
1749
1928
  */
1750
1929
  function claimEndIds(scope, nodes, where, reserved, copyPrefix = '') {
1751
1930
  const stepIds = new Map(nodes.steps.map((n) => [safeNodeId(n), n]));
@@ -1786,11 +1965,10 @@ export function serialize(built, opts = {}) {
1786
1965
  * node id. It is never quietly renamed:
1787
1966
  *
1788
1967
  * - Two steps whose names give the same id (`.step('x')` twice, or `a b`
1789
- * beside `a_b`) are refused. `check` reports them as DUP_STEP /
1790
- * STEP_ID_COLLISION; this is the backstop for every path that skips it
1791
- * (`compile --no-check`, the draft-tolerant merge compile, a direct
1792
- * `serialize()`). Renaming the second to `x2` left `.stepToRef('x')` and
1793
- * `$vars.x` pointing at the first, with no word said.
1968
+ * beside `a_b`) are refused as DUP_STEP / STEP_ID_COLLISION, on every
1969
+ * path (`check` reports it from its serializer run, with no step: it is
1970
+ * thrown before any node is emitted). Renaming the second to `x2` left
1971
+ * `.stepToRef('x')` and `$vars.x` pointing at the first, with no word said.
1794
1972
  * - In the flow itself, a step named like the trigger is refused the same
1795
1973
  * way (STEP_ID_COLLISION).
1796
1974
  * - A subflow body whose ids are already taken (a second copy of the same
@@ -1798,7 +1976,9 @@ export function serialize(built, opts = {}) {
1798
1976
  * place ids move: the whole copy is prefixed with the calling step's name,
1799
1977
  * and `rename` points its expressions at the prefixed ids.
1800
1978
  */
1801
- function claimStepIds(scope, names, where, copyPrefix, alsoIds = [], terminates = new Map()) {
1979
+ function claimStepIds(scope, names, where, copyPrefix, alsoIds = [], terminates = new Map(),
1980
+ /** A `.parallel` fork's name → its parallel, for a fork named like a step. */
1981
+ forks = new Map()) {
1802
1982
  const seen = new Map();
1803
1983
  for (const n of names) {
1804
1984
  const safe = n.replace(/[^A-Za-z0-9_]/g, '_') || 'node';
@@ -1813,9 +1993,12 @@ export function serialize(built, opts = {}) {
1813
1993
  + 'names that differ in letters, digits or "_".'
1814
1994
  : t >= 2 ? `${where}: [DUP_STEP] ${terminateTwiceMessage(n)}`
1815
1995
  : t === 1 ? `${where}: [DUP_STEP] ${terminateStepClashMessage(n)}`
1816
- : `${where}: [DUP_STEP] two steps are named "${n}". A step name is its node's id and \`.stepToRef\` / `
1817
- + `\`$vars\` name it, so compile will not rename the second one "${n}2". Give each step its own name; to `
1818
- + `send several paths to one step, keep one and end the others with .stepToRef('${n}').`);
1996
+ : forks.has(n) ? `${where}: [DUP_STEP] the fork of parallel "${forks.get(n)}" and another step are both `
1997
+ + `named "${n}". A step name is its node's id, so compile will not rename either one. Give one of `
1998
+ + 'them another name.'
1999
+ : `${where}: [DUP_STEP] two steps are named "${n}". A step name is its node's id and \`.stepToRef\` / `
2000
+ + `\`$vars\` name it, so compile will not rename the second one "${n}2". Give each step its own name; to `
2001
+ + `send several paths to one step, keep one and end the others with .stepToRef('${n}').`);
1819
2002
  }
1820
2003
  seen.set(safe, n);
1821
2004
  }
@@ -1837,20 +2020,35 @@ export function serialize(built, opts = {}) {
1837
2020
  * The node id of an agent resource: the author's `nodeId` exactly as written
1838
2021
  * (decompile writes the id the original file had, and a changed id is a
1839
2022
  * dropped node plus an added one to merge), else `uid`'s derived one. A
1840
- * `nodeId` another node already holds is refused rather than renamed, for
1841
- * the same reason; check reports it first (INLINE_AGENT_RESOURCE_NODE_ID).
2023
+ * `nodeId` another node already holds, or an entry point's id (claimed after
2024
+ * the body), is refused rather than renamed, for the same reason
2025
+ * (INLINE_AGENT_RESOURCE_NODE_ID). So is a derived id an explicit `nodeId`
2026
+ * already took: deriving `<id>2` instead would move a node the author's file
2027
+ * names by that id.
1842
2028
  */
1843
2029
  function resourceNodeId(scope, nodeId, derivedBase, where) {
1844
- if (nodeId === undefined)
2030
+ if (nodeId === undefined) {
2031
+ const owner = explicitResourceIds.get(derivedBase.replace(/[^A-Za-z0-9_]/g, '_') || 'node');
2032
+ if (owner !== undefined) {
2033
+ throw new CompileRefusal('INLINE_AGENT_RESOURCE_NODE_ID', `${owner} takes the \`nodeId\` ${JSON.stringify(derivedBase)}, which is the id compile derives for ${where}, `
2034
+ + 'and compile will not rename either one. Give that resource another `nodeId`, or omit it to derive one.');
2035
+ }
1845
2036
  return uid(scope, derivedBase);
1846
- if (nodeId.trim() === '' || takenIds.has(nodeId)) {
2037
+ }
2038
+ const entryPoint = (built.entryPoints ?? []).some((ep) => ep.id === nodeId);
2039
+ if (nodeId.trim() === '' || takenIds.has(nodeId) || entryPoint) {
1847
2040
  throw new CompileRefusal('INLINE_AGENT_RESOURCE_NODE_ID', `${where}: \`nodeId\` ${JSON.stringify(nodeId)} `
1848
- + (nodeId.trim() === '' ? 'is blank.' : 'is already the id of another node in this flow.')
2041
+ + (nodeId.trim() === '' ? 'is blank.'
2042
+ : entryPoint ? `is already the id of entry point "${nodeId}".`
2043
+ : 'is already the id of another node in this flow.')
1849
2044
  + ' Give the resource node an id no other node has, or omit `nodeId` to derive one.');
1850
2045
  }
1851
2046
  takenIds.add(nodeId);
2047
+ explicitResourceIds.set(nodeId, where);
1852
2048
  return nodeId;
1853
2049
  }
2050
+ /** Each explicit resource `nodeId` → the resource that took it (see `resourceNodeId`). */
2051
+ const explicitResourceIds = new Map();
1854
2052
  /**
1855
2053
  * The node id of an author's step: the one `claimStepIds` claimed for it,
1856
2054
  * recorded so `.stepToRef('<step>')` can find it.
@@ -1883,6 +2081,10 @@ export function serialize(built, opts = {}) {
1883
2081
  * `.stepToRef` rejoins now do come through here, #938).
1884
2082
  */
1885
2083
  const hitlNodes = new Map();
2084
+ /** Human tasks with no per-outcome exits, and why (`FlowNodeContribution.noOutcomePorts`). */
2085
+ const noOutcomePorts = new Map();
2086
+ /** Per human task in `hitlNodes`, how its main path leaves it (`hitlExitViolation`'s `routing`). */
2087
+ const hitlRouting = new Map();
1886
2088
  /**
1887
2089
  * Human tasks with outcomes that set `legacyCompleted: true`: they leave on
1888
2090
  * `completed` by the author's choice, so the guard exempts that one port
@@ -1903,9 +2105,26 @@ export function serialize(built, opts = {}) {
1903
2105
  const hitlEndExits = [];
1904
2106
  function addEdge(scope, from, toNode, toPort = 'input') {
1905
2107
  const hitlNode = hitlNodes.get(from.node);
1906
- const refused = hitlNode ? hitlExitViolation(hitlNode, from.port, legacyCompletedNodes.has(from.node)) : undefined;
1907
- if (refused)
1908
- throw new CompileRefusal(refused.code, `serialize: ${refused.message}`);
2108
+ const none = from.port.startsWith('outcome-') ? noOutcomePorts.get(from.node) : undefined;
2109
+ const refused = none
2110
+ ? {
2111
+ code: 'HITL_OUTCOME_PORTS_NONE',
2112
+ message: `Port "${from.port}" routes a per-outcome exit, but human task "${stepOfNode(scope, from.node) ?? from.node}" `
2113
+ + `has none — ${none.why}, so its single exit is 'completed'.`,
2114
+ suggestion: none.suggestion,
2115
+ }
2116
+ : hitlNode
2117
+ ? hitlExitViolation(hitlNode, from.port, legacyCompletedNodes.has(from.node), hitlRouting.get(from.node))
2118
+ : undefined;
2119
+ if (refused) {
2120
+ // On the task the edge leaves, not on the step it enters. In collect mode
2121
+ // the edge is not written, so no later guard trips on it.
2122
+ refuseOn(scope, stepOfNode(scope, from.node), new CompileRefusal(refused.code, `serialize: ${refused.message}`, refused.suggestion));
2123
+ return;
2124
+ }
2125
+ pushEdge(scope, from, toNode, toPort);
2126
+ }
2127
+ function pushEdge(scope, from, toNode, toPort = 'input') {
1909
2128
  scope.edges.push({ id: `edge_${edgeSeq++}`, sourceNodeId: from.node, sourcePort: from.port, targetNodeId: toNode, targetPort: toPort });
1910
2129
  }
1911
2130
  let col = 0;
@@ -2025,13 +2244,22 @@ export function serialize(built, opts = {}) {
2025
2244
  * defect that makes a subflow validate clean and then fault at run time with
2026
2245
  * a null. It is emitted here, always, so it cannot be forgotten.
2027
2246
  */
2028
- function emitSubflowBody(child, nodeId) {
2247
+ function emitSubflowBody(child, nodeId, stepName) {
2029
2248
  const outer = carriedDefs;
2030
2249
  carriedDefs = indexCarriedDefinitions(child.definitions ?? [], `subflow "${nodeId}" .definitions()`, new Map(outer));
2250
+ subflowPath.push(stepName);
2031
2251
  try {
2032
2252
  return emitSubflowBodyIn(child, nodeId);
2033
2253
  }
2254
+ catch (error) {
2255
+ // Each step of the body had its own catch, so this refusal is the body's
2256
+ // scope-level one: the calling step's catch must not step over it.
2257
+ if (opts.collect && error instanceof CompileRefusal)
2258
+ scopeRefusal = error;
2259
+ throw error;
2260
+ }
2034
2261
  finally {
2262
+ subflowPath.pop();
2035
2263
  carriedDefs = outer;
2036
2264
  }
2037
2265
  }
@@ -2049,7 +2277,8 @@ export function serialize(built, opts = {}) {
2049
2277
  // with its steps and move with it (#1253).
2050
2278
  const childNodes = namedNodesOf(child.steps);
2051
2279
  const childTerminates = new Map();
2052
- const prefix = claimStepIds(scope, stepNamesOf(child.steps, [], childTerminates), `subflow "${nodeId}"`, `${nodeId}_`, childNodes.ends.map(safeNodeId), childTerminates);
2280
+ const childForks = new Map();
2281
+ const prefix = claimStepIds(scope, stepNamesOf(child.steps, [], childTerminates, childForks), `subflow "${nodeId}"`, `${nodeId}_`, childNodes.ends.map(safeNodeId), childTerminates, childForks);
2053
2282
  claimEndIds(scope, childNodes, `subflow "${nodeId}"`, [], prefix);
2054
2283
  const startId = uid(scope, `${nodeId}Start`);
2055
2284
  // A CHILD's inputs are published as the child's own start node, whose id is
@@ -2083,7 +2312,7 @@ export function serialize(built, opts = {}) {
2083
2312
  }
2084
2313
  for (const vd of childGlobals.vars)
2085
2314
  globals.push({ id: vd.name, name: vd.name, direction: 'inout', type: vd.type, ...varMeta(vd) });
2086
- resolveRejoins(scope, addEdge);
2315
+ resolveRejoins(scope, addEdge, (from, refusal) => refuseOn(scope, stepOfNode(scope, from.node), refusal));
2087
2316
  assertHitlExitInvariant(scope.nodes, scope.edges, legacyCompletedNodes);
2088
2317
  typeAgentInputSchemas(scope, globals);
2089
2318
  emitStickyNotes(child, scope, claimNoteId);
@@ -2103,101 +2332,108 @@ export function serialize(built, opts = {}) {
2103
2332
  // node — its definition ships there with its `.v1def.json` sidecar — and it
2104
2333
  // replaces the start node's TYPE, exactly as the scheduled trigger does. The
2105
2334
  // node id stays `start`, so edges, `triggerNodeId` on flow inputs and the
2106
- // `output` success port are untouched.
2107
- const eventTriggerRc = built.trigger?.kind === 'event'
2108
- ? (() => {
2109
- if (!opts.library)
2110
- throw new CompileRefusal('LIBRARY_REQUIRED', `onEvent() needs a library (pass --library / set $FLOW_SDK_LIBRARY_JSON).`);
2111
- const sub = built.trigger.subscription;
2112
- const rc = opts.library.resolveEvent('trigger', sub.connector, sub.event, sub.version, sub.object);
2113
- refuseUnverifiedByoa(opts, 'trigger', sub);
2114
- return rc;
2115
- })()
2116
- : undefined;
2335
+ // `output` success port are untouched. In collect mode a refused trigger is
2336
+ // stood in (`emitRootNode`), so the steps after it are still judged.
2337
+ //
2117
2338
  // The trigger node's id is `start` unless the flow renamed it. `input('x')`
2118
2339
  // lowered eagerly to `$vars.start.output.x`, so a rename is applied by the
2119
2340
  // same `renameRefs` pass that rewrites step names (see `rename.nodeIds`).
2120
2341
  const triggerId = built.triggerId ?? 'start';
2121
- // A custom FlowTrigger subclass (no `kind`) compiles through its own
2122
- // contribution: exact definition, author inputs, definition-owned identity.
2123
- // When the manifest declares `model.entryPointId: true` (how the platform
2124
- // marks entry points), the serializer mints the stable entry-point identity
2125
- // exactly as it does for connector event triggers — an omitted one is the
2126
- // classic validates-then-faults defect, so it cannot be forgotten here.
2127
- const builtTrigger = built.trigger;
2128
- const customTrigger = builtTrigger instanceof FlowTrigger && !('kind' in builtTrigger) ? builtTrigger : undefined;
2129
- if (customTrigger) {
2130
- const contribution = customTrigger.contribute(built.triggerOptions?.version !== undefined ? { requestedVersion: built.triggerOptions.version } : {});
2131
- const def = resolveContributionDefinition(contribution.definition, built.triggerOptions?.version, 'trigger');
2132
- const node = makeNode(def.nodeType, triggerId, built.triggerOptions?.label ?? def.display?.label ?? 'Start', { def, requestedVersion: built.triggerOptions?.version });
2133
- const inputs = renderDeep(contribution.inputs ?? {}, NO_RENAME);
2134
- if (def.model?.entryPointId === true && inputs.entryPointId === undefined) {
2135
- inputs.entryPointId = stableId(`${built.id}:start:entryPoint`);
2136
- if (inputs.isDefaultEntryPoint === undefined)
2137
- inputs.isDefaultEntryPoint = true;
2138
- }
2139
- node.inputs = inputs;
2140
- hoistContributionBindings(def, contribution.bindings, 'trigger');
2141
- root.nodes.push(node);
2142
- }
2143
- // The author's `.trigger(spec, { label })`; makeNode falls back to the definition's label.
2144
- const rootLabel = built.triggerOptions?.label;
2145
- const trigger = customTrigger
2146
- ? root.nodes[root.nodes.length - 1]
2147
- : eventTriggerRc
2148
- ? makeNode(eventTriggerRc.nodeType, triggerId, rootLabel, { def: eventTriggerRc.v1def, requestedVersion: built.triggerOptions?.version })
2149
- : built.trigger?.kind === 'scheduled'
2150
- ? makeNode(NODE_TYPE.scheduledTrigger, triggerId, rootLabel, {
2151
- requestedVersion: scheduledVersion(built.trigger.inputs, built.triggerOptions?.version),
2152
- })
2153
- : built.trigger?.kind === 'form'
2154
- ? makeNode(NODE_TYPE.formTrigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version })
2155
- : built.trigger?.kind === 'conversation'
2156
- ? makeNode(NODE_TYPE.conversationTrigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version })
2157
- : built.trigger?.kind === 'voice'
2158
- ? makeNode(NODE_TYPE.voiceTrigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version })
2159
- : makeNode(NODE_TYPE.trigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version });
2160
- if (!customTrigger) {
2161
- trigger.inputs = eventTriggerRc && built.trigger?.kind === 'event'
2162
- ? {
2163
- // The deployed shape: 38 of 39 archived instances carry `entryPointId`,
2164
- // 28 carry `isDefaultEntryPoint: true`, and the subscription lives in
2165
- // `detail` (see eventDetail).
2166
- entryPointId: stableId(`${built.id}:start:entryPoint`),
2167
- isDefaultEntryPoint: true,
2168
- detail: eventDetail(eventTriggerRc, built.trigger.subscription, triggerId, opts, 'event trigger'),
2342
+ const trigger = emitRootNode('trigger', triggerId, built.trigger, built.triggerOptions?.version, () => {
2343
+ const eventTriggerRc = built.trigger?.kind === 'event'
2344
+ ? (() => {
2345
+ const sub = built.trigger.subscription;
2346
+ if (!opts.library)
2347
+ throw libraryRequiredForEvent(opts, sub, built.triggerId ?? 'start', 'event trigger', 'onEvent()');
2348
+ const rc = resolveEvent(opts.library, 'trigger', sub, 'trigger');
2349
+ refuseUnverifiedByoa(opts, 'trigger', sub);
2350
+ return rc;
2351
+ })()
2352
+ : undefined;
2353
+ // A custom FlowTrigger subclass (no `kind`) compiles through its own
2354
+ // contribution: exact definition, author inputs, definition-owned identity.
2355
+ // When the manifest declares `model.entryPointId: true` (how the platform
2356
+ // marks entry points), the serializer mints the stable entry-point identity
2357
+ // exactly as it does for connector event triggers — an omitted one is the
2358
+ // classic validates-then-faults defect, so it cannot be forgotten here.
2359
+ const builtTrigger = built.trigger;
2360
+ const customTrigger = builtTrigger instanceof FlowTrigger && !('kind' in builtTrigger) ? builtTrigger : undefined;
2361
+ if (built.trigger !== undefined && !customTrigger)
2362
+ refuseUnknownTrigger(built.trigger, '.trigger()');
2363
+ if (customTrigger) {
2364
+ const contribution = contributionOf(customTrigger, built.triggerOptions?.version, 'trigger');
2365
+ const def = resolveContributionDefinition(contribution.definition, built.triggerOptions?.version, 'trigger');
2366
+ const node = makeNode(def.nodeType, triggerId, built.triggerOptions?.label ?? def.display?.label ?? 'Start', { def, requestedVersion: built.triggerOptions?.version });
2367
+ const inputs = renderDeep(contribution.inputs ?? {}, NO_RENAME);
2368
+ if (def.model?.entryPointId === true && inputs.entryPointId === undefined) {
2369
+ inputs.entryPointId = stableId(`${built.id}:start:entryPoint`);
2370
+ if (inputs.isDefaultEntryPoint === undefined)
2371
+ inputs.isDefaultEntryPoint = true;
2169
2372
  }
2170
- : built.trigger?.kind === 'scheduled'
2171
- ? scheduleInputs(built.trigger.inputs, triggerId)
2172
- : built.trigger?.kind === 'form'
2173
- ? {
2174
- // A GUID: publish keys the form's FormTrigger binding on it and skips
2175
- // the form when it is not one.
2176
- entryPointId: stableId(`${built.id}:${triggerId}:entryPoint`),
2177
- schema: { fields: formTriggerFields(built.inputs), outcomes: [] },
2178
- }
2179
- : built.trigger?.kind === 'conversation' || built.trigger?.kind === 'voice'
2180
- // Both are entry points (`model.entryPointId: true`) with nothing else
2181
- // authored: what the platform started — the conversation, or the live
2182
- // call's `callContext` — is the trigger's own declared output.
2183
- ? { entryPointId: triggerId }
2184
- : {};
2185
- }
2186
- if (eventTriggerRc && built.trigger?.kind === 'event') {
2187
- // An event trigger's def declares the same connection binding names a
2188
- // mid-flow connector's does — close them (G-19; see connectionResourceBindings).
2189
- const sub = built.trigger.subscription;
2190
- const { entries } = connectionResourceBindings({
2191
- def: eventTriggerRc.v1def,
2192
- connectorKey: eventTriggerRc.connectorKey,
2193
- connectionId: resolveBinding(opts, sub.connection, 'connection', 'event trigger', eventTarget(sub)),
2194
- folderKey: resolveBinding(opts, sub.folder, 'folder', 'event trigger', eventTarget(sub)),
2195
- }, resourceBindings);
2196
- for (const b of entries)
2197
- resourceBindings.set(b.id, b);
2198
- }
2199
- if (!customTrigger)
2200
- root.nodes.push(trigger);
2373
+ node.inputs = inputs;
2374
+ hoistContributionBindings(def, contribution.bindings, 'trigger');
2375
+ root.nodes.push(node);
2376
+ }
2377
+ // The author's `.trigger(spec, { label })`; makeNode falls back to the definition's label.
2378
+ const rootLabel = built.triggerOptions?.label;
2379
+ const trigger = customTrigger
2380
+ ? root.nodes[root.nodes.length - 1]
2381
+ : eventTriggerRc
2382
+ ? makeNode(eventTriggerRc.nodeType, triggerId, rootLabel, { def: eventTriggerRc.v1def, requestedVersion: built.triggerOptions?.version })
2383
+ : built.trigger?.kind === 'scheduled'
2384
+ ? makeNode(NODE_TYPE.scheduledTrigger, triggerId, rootLabel, {
2385
+ requestedVersion: scheduledVersion(built.trigger.inputs, built.triggerOptions?.version),
2386
+ })
2387
+ : built.trigger?.kind === 'form'
2388
+ ? makeNode(NODE_TYPE.formTrigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version })
2389
+ : built.trigger?.kind === 'conversation'
2390
+ ? makeNode(NODE_TYPE.conversationTrigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version })
2391
+ : built.trigger?.kind === 'voice'
2392
+ ? makeNode(NODE_TYPE.voiceTrigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version })
2393
+ : makeNode(NODE_TYPE.trigger, triggerId, rootLabel, { requestedVersion: built.triggerOptions?.version });
2394
+ if (!customTrigger) {
2395
+ trigger.inputs = eventTriggerRc && built.trigger?.kind === 'event'
2396
+ ? {
2397
+ // The deployed shape: 38 of 39 archived instances carry `entryPointId`,
2398
+ // 28 carry `isDefaultEntryPoint: true`, and the subscription lives in
2399
+ // `detail` (see eventDetail).
2400
+ entryPointId: stableId(`${built.id}:start:entryPoint`),
2401
+ isDefaultEntryPoint: true,
2402
+ detail: eventDetail(eventTriggerRc, built.trigger.subscription, triggerId, opts, 'event trigger'),
2403
+ }
2404
+ : built.trigger?.kind === 'scheduled'
2405
+ ? scheduleInputs(built.trigger.inputs, triggerId)
2406
+ : built.trigger?.kind === 'form'
2407
+ ? {
2408
+ // A GUID: publish keys the form's FormTrigger binding on it and skips
2409
+ // the form when it is not one.
2410
+ entryPointId: stableId(`${built.id}:${triggerId}:entryPoint`),
2411
+ schema: { fields: formTriggerFields(built.inputs), outcomes: [] },
2412
+ }
2413
+ : built.trigger?.kind === 'conversation' || built.trigger?.kind === 'voice'
2414
+ // Both are entry points (`model.entryPointId: true`) with nothing else
2415
+ // authored: what the platform started — the conversation, or the live
2416
+ // call's `callContext` — is the trigger's own declared output.
2417
+ ? { entryPointId: triggerId }
2418
+ : {};
2419
+ }
2420
+ if (eventTriggerRc && built.trigger?.kind === 'event') {
2421
+ // An event trigger's def declares the same connection binding names a
2422
+ // mid-flow connector's does — close them (G-19; see connectionResourceBindings).
2423
+ const sub = built.trigger.subscription;
2424
+ const { entries } = connectionResourceBindings({
2425
+ def: eventTriggerRc.v1def,
2426
+ connectorKey: eventTriggerRc.connectorKey,
2427
+ connectionId: resolveBinding(opts, sub.connection, 'connection', 'event trigger', eventTarget(sub)),
2428
+ folderKey: resolveBinding(opts, sub.folder, 'folder', 'event trigger', eventTarget(sub)),
2429
+ }, resourceBindings);
2430
+ for (const b of entries)
2431
+ resourceBindings.set(b.id, b);
2432
+ }
2433
+ if (!customTrigger)
2434
+ root.nodes.push(trigger);
2435
+ return trigger;
2436
+ });
2201
2437
  function successPort(type) {
2202
2438
  if (type === NODE_TYPE.loop)
2203
2439
  return 'success';
@@ -2216,7 +2452,7 @@ export function serialize(built, opts = {}) {
2216
2452
  */
2217
2453
  function emitContribution(spec, stepName, requestedVersion, tails, scope, parentId) {
2218
2454
  const where = `step "${stepName}"`;
2219
- const contribution = spec.contribute(requestedVersion !== undefined ? { requestedVersion } : {});
2455
+ const contribution = contributionOf(spec, requestedVersion, where);
2220
2456
  const def = resolveContributionDefinition(contribution.definition, requestedVersion, where);
2221
2457
  // A connector definition reached this path only through a raw or hand-written
2222
2458
  // contribution — `connector()` never does — and would be emitted without
@@ -2269,10 +2505,19 @@ export function serialize(built, opts = {}) {
2269
2505
  // edge out of it is held to that rule (`hitlExitViolation`, in addEdge), and
2270
2506
  // the step list treats its tails as outcome edges. A rawNode of the type is
2271
2507
  // not one: it routes by the port lists its author wrote.
2508
+ if (contribution.noOutcomePorts)
2509
+ noOutcomePorts.set(id, contribution.noOutcomePorts);
2272
2510
  if (o.trackOutcomes && contribution.legacyCompleted)
2273
2511
  legacyCompletedNodes.add(id);
2274
- else if (o.trackOutcomes && OUTCOME_HANDLE_NODE_TYPES.has(o.typeName))
2512
+ else if (o.trackOutcomes && OUTCOME_HANDLE_NODE_TYPES.has(o.typeName)) {
2275
2513
  hitlNodes.set(id, node);
2514
+ // How its main path leaves it: every outcome (a fan-out), or the primary one.
2515
+ const port = contribution.port;
2516
+ if (contribution.fanOut)
2517
+ hitlRouting.set(id, 'every-outcome');
2518
+ else if (port?.startsWith('outcome-'))
2519
+ hitlRouting.set(id, { primary: port });
2520
+ }
2276
2521
  for (const a of contribution.attach ?? []) {
2277
2522
  const def = a.definition;
2278
2523
  const rnode = makeNode(def.nodeType, a.id, a.label, { parentId: o.parentId, def });
@@ -2367,7 +2612,7 @@ export function serialize(built, opts = {}) {
2367
2612
  resourceBindings.set(b.id, b);
2368
2613
  },
2369
2614
  agentSource: (carried) => agentFolder(carried, step.name),
2370
- sidecar: (file, explicitSource) => addSidecar(file, explicitSource),
2615
+ sidecar: (file, owner) => addSidecar(file, owner === true ? 'another agent or resource' : owner === false ? undefined : owner),
2371
2616
  typeAgentInputs: (schema, refs) => { scope.agentSchemas.push({ schema: schema, refs: [...refs] }); },
2372
2617
  resourceNodeId: (explicit, seed, at) => resourceNodeId(scope, explicit, seed, at),
2373
2618
  emitted: (typeName, v) => emittedDefs.get(`${typeName}@${v}`),
@@ -2484,6 +2729,169 @@ export function serialize(built, opts = {}) {
2484
2729
  rendered,
2485
2730
  });
2486
2731
  }
2732
+ /**
2733
+ * What a throw out of one node's emission is, in collect mode: a refusal as
2734
+ * it is, and any other throw (a `TypeError` on a spec the serializer has no
2735
+ * refusal for) as COMPILE_FAILED, which is what `compile` reports for it. An
2736
+ * `internal SDK error:`, and a subflow body's scope-level refusal, are passed on.
2737
+ */
2738
+ function stepRefusal(error) {
2739
+ if (error === scopeRefusal)
2740
+ throw error;
2741
+ if (error instanceof CompileRefusal)
2742
+ return error;
2743
+ if (!(error instanceof Error) || error.message.startsWith('internal SDK error:'))
2744
+ throw error;
2745
+ return new CompileRefusal('COMPILE_FAILED', error.message);
2746
+ }
2747
+ /** Report `refusal` to collect mode, on `step` (node `nodeId`) when it has one. */
2748
+ function record(refusal, step, nodeId) {
2749
+ opts.collect?.({
2750
+ code: refusal.code, message: refusal.message,
2751
+ ...(refusal.suggestion !== undefined ? { suggestion: refusal.suggestion } : {}),
2752
+ ...(step !== undefined ? { step, ...(nodeId !== undefined ? { nodeId } : {}) } : {}),
2753
+ ...(step !== undefined && subflowPath.length ? { subflows: [...subflowPath] } : {}),
2754
+ });
2755
+ }
2756
+ /**
2757
+ * Refuse at a site past a step's own emission (an edge out of it, a port list
2758
+ * on it, a join it feeds), on the step the refusal is about. Collect mode
2759
+ * records it there and goes on with the graph as authored, so later steps are
2760
+ * still judged; any other compile throws it. With no owning step it throws in
2761
+ * collect mode too: then it is scope-level.
2762
+ */
2763
+ function refuseOn(scope, stepName, refusal) {
2764
+ if (!opts.collect || stepName === undefined)
2765
+ throw refusal;
2766
+ record(refusal, stepName, scope.stepNodeId.get(stepName) ?? scope.pending.get(stepName));
2767
+ }
2768
+ /** The step whose node `nodeId` is, in `scope`. */
2769
+ function stepOfNode(scope, nodeId) {
2770
+ for (const [name, id] of scope.stepNodeId)
2771
+ if (id === nodeId)
2772
+ return name;
2773
+ return undefined;
2774
+ }
2775
+ /**
2776
+ * Emit one root's trigger node (the flow's trigger, an entry point), standing
2777
+ * it in when its emission is refused in collect mode, as `emitStep` does for a
2778
+ * step: reported on `name`, and the steps after it are still judged.
2779
+ */
2780
+ function emitRootNode(name, id, spec, version, emitNode) {
2781
+ if (!opts.collect)
2782
+ return emitNode();
2783
+ const mark = { nodes: root.nodes.length, resources: new Set(resourceBindings.keys()) };
2784
+ try {
2785
+ return emitNode();
2786
+ }
2787
+ catch (error) {
2788
+ const refusal = stepRefusal(error);
2789
+ record(refusal, name, id);
2790
+ root.nodes.length = mark.nodes;
2791
+ for (const key of [...resourceBindings.keys()])
2792
+ if (!mark.resources.has(key))
2793
+ resourceBindings.delete(key);
2794
+ unboundDrafts.add(id);
2795
+ const node = standInNode(name, spec, version, id, undefined);
2796
+ root.nodes.push(node);
2797
+ return node;
2798
+ }
2799
+ }
2800
+ /**
2801
+ * Emit one action step and `finish` it (its updates and label). In collect
2802
+ * mode (`SerializeOpts.collect`) this is the per-step catch: a refusal is
2803
+ * reported on the step, everything the step had emitted is undone, and a
2804
+ * stand-in node takes its place, wired to the same tails.
2805
+ */
2806
+ function emitStep(step, tails, scope, parentId, finish) {
2807
+ const collect = opts.collect;
2808
+ if (!collect) {
2809
+ const out = emitAction(step, tails, scope, parentId);
2810
+ finish();
2811
+ return out;
2812
+ }
2813
+ const mark = {
2814
+ nodes: scope.nodes.length, edges: scope.edges.length, pendingEdges: scope.pendingEdges.length,
2815
+ agentSchemas: scope.agentSchemas.length, sidecars: sidecars.length,
2816
+ };
2817
+ try {
2818
+ const out = emitAction(step, tails, scope, parentId);
2819
+ finish();
2820
+ return out;
2821
+ }
2822
+ catch (error) {
2823
+ const refusal = stepRefusal(error);
2824
+ const id = scope.stepNodeId.get(step.name) ?? stepUid(scope, step.name);
2825
+ record(refusal, step.name, id);
2826
+ scope.nodes.length = mark.nodes;
2827
+ scope.edges.length = mark.edges;
2828
+ scope.pendingEdges.length = mark.pendingEdges;
2829
+ scope.agentSchemas.length = mark.agentSchemas;
2830
+ for (const f of sidecars.splice(mark.sidecars)) {
2831
+ if (!sidecars.some((kept) => kept.path === f.path))
2832
+ explicitSidecars.delete(f.path);
2833
+ }
2834
+ delete scope.variableUpdates[id];
2835
+ delete subflows[id];
2836
+ // An error port recorded before the throw is the type's answer; keep it for the stand-in.
2837
+ const knownPort = declaredErrorPorts.get(id);
2838
+ for (const byId of [contributionDefs, bundledNodeDefs, declaredErrorPorts, hitlNodes, hitlRouting, noOutcomePorts])
2839
+ byId.delete(id);
2840
+ legacyCompletedNodes.delete(id);
2841
+ capsOpenTail.delete(id);
2842
+ routerNodes.delete(step.name);
2843
+ // No binding rows and no definition: the placeholder check passes it, as a draft's.
2844
+ unboundDrafts.add(id);
2845
+ scope.nodes.push(standInNode(step.name, step.spec, step.options?.version, id, parentId));
2846
+ // Its error port is its type's, so an `.onError` after it is still judged.
2847
+ const port = standInErrorPort(step) ?? knownPort;
2848
+ if (port)
2849
+ declaredErrorPorts.set(id, port);
2850
+ // Wired as any node is: an edge a human task does not have is refused on
2851
+ // the task (`addEdge`, collect mode) and not written.
2852
+ for (const t of tails) {
2853
+ try {
2854
+ addEdge(scope, t, id);
2855
+ }
2856
+ catch (edgeError) {
2857
+ if (!(edgeError instanceof CompileRefusal))
2858
+ throw edgeError;
2859
+ record(edgeError);
2860
+ }
2861
+ }
2862
+ return [{ node: id, port: 'output' }];
2863
+ }
2864
+ }
2865
+ /**
2866
+ * Whether a refused step's node type has an error port, as its emission would
2867
+ * have recorded it: a registered type's declaration or bundled definition.
2868
+ * `undefined` when the type cannot say (a custom node, a pin no version
2869
+ * resolves) and nothing was recorded before the throw: its edge is not judged.
2870
+ */
2871
+ function standInErrorPort(step) {
2872
+ const spec = step.spec;
2873
+ const entry = typeof spec.kind === 'string' ? FLOW_NODE_TYPES.byKind(spec.kind, spec) : undefined;
2874
+ if (!entry)
2875
+ return undefined;
2876
+ try {
2877
+ const resolved = resolveVersionCarried(entry.type, spec, step.options?.version, `step "${step.name}"`, carriedFor);
2878
+ const impl = resolved.impl;
2879
+ if (resolved.carried !== undefined) {
2880
+ return { port: definitionHasErrorPort(resolved.carried), typeName: impl.typeName, version: resolved.version };
2881
+ }
2882
+ if (impl.definition === 'bundled') {
2883
+ const def = bundledDefinition(impl.typeName, resolved.version);
2884
+ return def === undefined ? undefined : { port: definitionHasErrorPort(def), typeName: def.nodeType, version: def.version };
2885
+ }
2886
+ const port = typeof impl.errorPort === 'function' ? impl.errorPort(spec, undefined) : impl.errorPort === true;
2887
+ return { port, typeName: impl.typeName, version: resolved.version };
2888
+ }
2889
+ catch (error) {
2890
+ if (error instanceof CompileRefusal)
2891
+ return undefined;
2892
+ throw error;
2893
+ }
2894
+ }
2487
2895
  function emitAction(step, tails, scope, parentId) {
2488
2896
  const spec = step.spec;
2489
2897
  // A registered node type serializes itself; every other kind falls through to
@@ -2515,17 +2923,24 @@ export function serialize(built, opts = {}) {
2515
2923
  addEdge(scope, t, id);
2516
2924
  // Emit the body AFTER the node exists, so a child that itself calls a
2517
2925
  // subflow registers its own entry in the same flat map.
2518
- subflows[id] = emitSubflowBody(spec.child, id);
2926
+ subflows[id] = emitSubflowBody(spec.child, id, step.name);
2519
2927
  return [{ node: id, port: successPort(NODE_TYPE.subflow) }];
2520
2928
  }
2521
2929
  if (spec.kind === 'waitEvent') {
2522
- if (!opts.library)
2523
- throw new CompileRefusal('LIBRARY_REQUIRED', `waitForEvent "${step.name}" needs a library (pass --library / set $FLOW_SDK_LIBRARY_JSON).`);
2524
2930
  const sub = spec.subscription;
2525
- const rc = opts.library.resolveEvent('event', sub.connector, sub.event, sub.version, sub.object);
2931
+ // An event wait resumes or it does not: it has no error port to route.
2932
+ // Recorded before anything can refuse the step, so a stand-in keeps it.
2933
+ declaredErrorPorts.set(scope.pending.get(step.name) ?? step.name, {
2934
+ port: false, typeName: `uipath.connector.event.${sub?.connector}.${sub?.event}`, version: step.options?.version ?? sub?.version ?? '1.0.0',
2935
+ });
2936
+ if (!opts.library) {
2937
+ throw libraryRequiredForEvent(opts, sub, scope.pending.get(step.name) ?? step.name, `waitForEvent "${step.name}"`, `waitForEvent "${step.name}"`);
2938
+ }
2939
+ const rc = resolveEvent(opts.library, 'event', sub, `waitForEvent "${step.name}"`);
2526
2940
  refuseUnverifiedByoa(opts, `waitForEvent "${step.name}"`, sub);
2527
2941
  const id = stepUid(scope, step.name);
2528
2942
  const node = makeNode(rc.nodeType, id, step.name, { parentId, requestedVersion: step.options?.version, def: rc.v1def });
2943
+ declaredErrorPorts.set(id, { port: false, typeName: rc.nodeType, version: String(rc.v1def?.version ?? node.typeVersion) });
2529
2944
  node.inputs = { detail: eventDetail(rc, sub, id, opts, `waitForEvent "${step.name}"`) };
2530
2945
  // Same connection-binding closure as the connector branch (G-19).
2531
2946
  const { entries: waitConnBindings } = connectionResourceBindings({
@@ -2594,12 +3009,11 @@ export function serialize(built, opts = {}) {
2594
3009
  /**
2595
3010
  * Refuse a side exit on a human task's port that the step's tails already
2596
3011
  * continue from: that would put two edges on one port (flow-builder-sdk#741).
2597
- * After a fan-out task that is EVERY outcome port. `check` reports the same
2598
- * as HITL_OUTCOME_PORTS_OFF; this is the compile-side half. A
3012
+ * After a fan-out task that is EVERY outcome port (HITL_OUTCOME_PORTS_OFF). A
2599
3013
  * `legacyCompleted` task counts too: its main path leaves on `completed`, so a
2600
3014
  * side route there is the same second edge.
2601
3015
  */
2602
- function refuseRewire(method, port, source, tails) {
3016
+ function refuseRewire(scope, method, port, source, tails) {
2603
3017
  const node = tails[0]?.node ?? '';
2604
3018
  if (!(hitlNodes.has(node) || legacyCompletedNodes.has(node)) || !tails.some((t) => t.port === port))
2605
3019
  return;
@@ -2607,12 +3021,14 @@ export function serialize(built, opts = {}) {
2607
3021
  // outcome, which is check's HITL_OUTCOME_PORTS_OFF. One tail: that port
2608
3022
  // already continues the main path (a routed task's primary outcome, or a
2609
3023
  // legacyCompleted task's `completed`), and outcomePorts would not help.
2610
- throw new CompileRefusal(tails.length > 1 ? 'HITL_OUTCOME_PORTS_OFF' : 'HITL_PORT_ALREADY_WIRED', tails.length > 1
3024
+ refuseOn(scope, source, new CompileRefusal(tails.length > 1 ? 'HITL_OUTCOME_PORTS_OFF' : 'HITL_PORT_ALREADY_WIRED', tails.length > 1
2611
3025
  ? `${method}('${port}', …) after human task "${source}": every outcome already continues to the next `
2612
3026
  + `step, '${port}' included, because this task does not route per outcome. Add outcomePorts: true `
2613
3027
  + `(the first outcome then continues and the others are routed), or route every outcome with .stepSwitch.`
2614
3028
  : `${method}('${port}', …) after human task "${source}": '${port}' already continues the main path, so `
2615
- + `routing it here too would put two edges on one port (flow-builder-sdk#741).`);
3029
+ + `routing it here too would put two edges on one port (flow-builder-sdk#741).`, tails.length > 1
3030
+ ? 'outcomePorts: true (then the first outcome continues and the others are routed), or route every outcome with .stepSwitch'
3031
+ : undefined));
2616
3032
  }
2617
3033
  /**
2618
3034
  * A step list hanging off a named PORT of the step just emitted.
@@ -2635,7 +3051,7 @@ export function serialize(built, opts = {}) {
2635
3051
  if (tails.length === 0 || tails.some((t) => t.node !== tails[0].node)) {
2636
3052
  throw new Error(`internal SDK error: .stepToList('${step.port}', …) after step "${source}": expected the step to leave from exactly one node, got ${tails.length} tail(s).`);
2637
3053
  }
2638
- refuseRewire('.stepToList', step.port, source, tails);
3054
+ refuseRewire(scope, '.stepToList', step.port, source, tails);
2639
3055
  const nodeId = tails[0].node;
2640
3056
  const node = scope.nodes.find((n) => n.id === nodeId);
2641
3057
  if (!node)
@@ -2643,7 +3059,7 @@ export function serialize(built, opts = {}) {
2643
3059
  // Only the error port carries this: without it the platform routes but Studio
2644
3060
  // Web HIDES the handle, so no human can see the handler.
2645
3061
  if (step.port === 'error') {
2646
- refuseErrorEdgeWithoutPort(nodeId, source);
3062
+ refuseErrorEdgeWithoutPort(scope, nodeId, source);
2647
3063
  node.inputs = { ...(node.inputs ?? {}), errorHandlingEnabled: true };
2648
3064
  }
2649
3065
  const handlerTails = emit(handler, [{ node: nodeId, port: step.port }], scope, parentId, loopExits);
@@ -2709,14 +3125,12 @@ export function serialize(built, opts = {}) {
2709
3125
  // follow: nothing continues from it, so a step here would hang off no edge.
2710
3126
  const router = lastAction !== undefined && tails.length === 0 ? routerNodes.get(lastAction) : undefined;
2711
3127
  if (router !== undefined && step.kind !== 'stepToList' && step.kind !== 'stepToRef') {
2712
- throw new CompileRefusal('TEMPLATED_EXITS_ONLY', `step "${lastAction}": its definition declares only templated exits (such as case-{item.id}), so no `
3128
+ refuseOn(scope, lastAction, new CompileRefusal('TEMPLATED_EXITS_ONLY', `step "${lastAction}": its definition declares only templated exits (such as case-{item.id}), so no `
2713
3129
  + `path continues from it and ${'name' in step ? `"${step.name}"` : `.${step.kind}()`} after it would `
2714
- + `have no incoming edge. Route each exit with .stepToList('<port>', …), e.g. .stepToList('case-<id>', …).`);
3130
+ + `have no incoming edge. Route each exit with .stepToList('<port>', …), e.g. .stepToList('case-<id>', …).`));
2715
3131
  }
2716
3132
  if (step.kind === 'action') {
2717
- tails = emitAction(step, tails, scope, parentId);
2718
- applyUpdates(step);
2719
- applyLabel(step);
3133
+ tails = emitStep(step, tails, scope, parentId, () => { applyUpdates(step); applyLabel(step); });
2720
3134
  lastAction = step.name;
2721
3135
  // A fan-out human task's `action: 'End'` outcomes end the run here: the
2722
3136
  // flow runtime never reads `action`, so only an End node can. When the
@@ -2750,11 +3164,13 @@ export function serialize(built, opts = {}) {
2750
3164
  // no routable fan-out (a human task pinned to 1.0, a variant, an http
2751
3165
  // with no branches) must refuse rather than emit a node and then
2752
3166
  // discover it has one port. `stepExits` carries the reason.
2753
- const exits = stepExits(step.spec, step.options?.version);
2754
- if (!hasExits(exits)) {
2755
- throw new CompileRefusal('STEP_SWITCH_NO_EXITS', `.stepSwitch("${step.name}"): ${exits.reason}, so there are no exits to route.`
2756
- + (exits.suggestion ? ` ${exits.suggestion}` : ''));
3167
+ const declaredExits = stepExits(step.spec, step.options?.version);
3168
+ if (!hasExits(declaredExits)) {
3169
+ refuseOn(scope, step.name, new CompileRefusal('STEP_SWITCH_NO_EXITS', `.stepSwitch("${step.name}"): ${declaredExits.reason}, so there are no exits to route.`, declaredExits.suggestion));
2757
3170
  }
3171
+ // In collect mode, past a refusal, each arm is still emitted (from the
3172
+ // port its value names), so the steps in it are judged.
3173
+ const exits = hasExits(declaredExits) ? declaredExits : [];
2758
3174
  const action = {
2759
3175
  kind: 'action', name: step.name, spec: step.spec,
2760
3176
  ...(step.options ? { options: step.options } : {}),
@@ -2763,9 +3179,7 @@ export function serialize(built, opts = {}) {
2763
3179
  // primary outcome, http's `default`. Discarded on purpose: every exit is
2764
3180
  // an arm here, so that port is wired by the arm that names it (and
2765
3181
  // wiring both is flow-builder-sdk#741).
2766
- emitAction(action, tails, scope, parentId);
2767
- applyUpdates(action);
2768
- applyLabel(action);
3182
+ emitStep(action, tails, scope, parentId, () => { applyUpdates(action); applyLabel(action); });
2769
3183
  const nodeId = scope.stepNodeId.get(step.name);
2770
3184
  if (!nodeId)
2771
3185
  throw new Error(`internal SDK error: .stepSwitch("${step.name}"): emitted node not found.`);
@@ -2775,11 +3189,13 @@ export function serialize(built, opts = {}) {
2775
3189
  // in `.return()` here, an outcome with no arm below.
2776
3190
  const stopped = [];
2777
3191
  for (const c of step.cases) {
2778
- const exit = exits.find((e) => e.value === c.value);
2779
- if (!exit) {
2780
- throw new CompileRefusal('STEP_SWITCH_EXIT_UNKNOWN', `.stepSwitch("${step.name}"): no exit named ${JSON.stringify(c.value)}. `
2781
- + `It declares ${exits.map((e) => JSON.stringify(e.value)).join(', ')}.`);
3192
+ const found = exits.find((e) => e.value === c.value);
3193
+ if (!found && hasExits(declaredExits)) {
3194
+ const nearest = nearestName(c.value, exits.map((e) => e.value));
3195
+ refuseOn(scope, step.name, new CompileRefusal('STEP_SWITCH_EXIT_UNKNOWN', `.stepSwitch("${step.name}") has an arm for ${JSON.stringify(c.value)}, which is not `
3196
+ + `an exit it declares — those are ${exits.map((e) => JSON.stringify(e.value)).join(', ')}.`, nearest !== undefined ? `value: '${nearest}'` : undefined));
2782
3197
  }
3198
+ const exit = found ?? { value: c.value, port: String(c.value) };
2783
3199
  routed.add(exit.port);
2784
3200
  // Arms CONVERGE, exactly as `.switch()`'s do: every arm that does not
2785
3201
  // end in `.return()` contributes its tail, so the next step fans in
@@ -2899,9 +3315,9 @@ export function serialize(built, opts = {}) {
2899
3315
  // The join waits for a token on EVERY incoming edge, and a fan-out human
2900
3316
  // task's outcome edges are mutually exclusive — only one fires. So two
2901
3317
  // of them straight into the join, or an End outcome beside a continuing
2902
- // one, hang the run with no incident. check reports both
2903
- // (HITL_FAN_OUT_INTO_JOIN, HITL_END_OUTCOME_IN_PARALLEL); this is the
2904
- // compile-side half, and it fails closed.
3318
+ // one, hang the run with no incident: HITL_FAN_OUT_INTO_JOIN and
3319
+ // HITL_END_OUTCOME_IN_PARALLEL, worded from the step tree
3320
+ // (`flow-parallel-hitl.ts`). It fails closed.
2905
3321
  // The fork, when the author names one (`{ fork }`, #1310): a merge node
2906
3322
  // with one incoming edge and one edge per arm — the gateway Studio Web
2907
3323
  // draws for a parallel split. The arms then leave from it instead of
@@ -2913,9 +3329,9 @@ export function serialize(built, opts = {}) {
2913
3329
  // fan-out task's outcomes — must not both enter it: only one fires
2914
3330
  // and the arms never start. Fails closed, as the join does.
2915
3331
  if (tails.length > 1) {
2916
- throw new CompileRefusal('PARALLEL_FORK_ALTERNATIVES', `.parallel("${step.name}"): its fork "${fork.name}" would receive ${tails.length} alternative edges `
3332
+ refuseOn(scope, step.name, new CompileRefusal('PARALLEL_FORK_ALTERNATIVES', `.parallel("${step.name}"): its fork "${fork.name}" would receive ${tails.length} alternative edges `
2917
3333
  + `(${tails.map((t) => `${t.node}@${t.port}`).join(', ')}), and only one of them fires while the gateway `
2918
- + `waits for all of them. Add a step before the .parallel so the paths meet there first, or drop { fork }.`);
3334
+ + `waits for all of them. Add a step before the .parallel so the paths meet there first, or drop { fork }.`));
2919
3335
  }
2920
3336
  const forkId = stepUid(scope, fork.name);
2921
3337
  const forkNode = makeNode(NODE_TYPE.merge, forkId, fork.label ?? fork.name, { parentId, requestedVersion: fork.version ?? step.options?.version });
@@ -2927,8 +3343,13 @@ export function serialize(built, opts = {}) {
2927
3343
  }
2928
3344
  const armTails = [];
2929
3345
  const endsBefore = hitlEndExits.length;
2930
- for (const arm of step.arms) {
3346
+ for (const [armIndex, arm] of step.arms.entries()) {
2931
3347
  const t = emit(arm, tails, scope, parentId, loopExits);
3348
+ // Worded from the step tree, which names the outcomes; this graph's tails decide.
3349
+ const fanIn = () => {
3350
+ const treeTails = hitlFanInto(arm);
3351
+ return treeTails && fanInRefusal(step.name, armIndex, arm, treeTails);
3352
+ };
2932
3353
  // An arm hands the join ONE edge, or several that are alternatives —
2933
3354
  // `.branch` / `.switch` / `.stepSwitch` arms, a fan-out task's
2934
3355
  // outcomes — of which only one fires. Refused when any of them is a
@@ -2941,26 +3362,34 @@ export function serialize(built, opts = {}) {
2941
3362
  // a complete `.stepSwitch` with nothing after it hands the join one
2942
3363
  // edge per arm, and still only one arm runs.
2943
3364
  const switchTail = outcomeTail ? undefined : t.find((x) => x.humanSwitch !== undefined);
3365
+ // Each is the human task's refusal, so it is reported on the task.
2944
3366
  if (t.length >= 2 && switchTail) {
2945
- throw new CompileRefusal('HITL_FAN_OUT_INTO_JOIN', `.parallel("${step.name}"): an arm sends ${t.length} alternative edges into join "${step.name}" — `
2946
- + `among them the end of an arm of human task "${switchTail.humanSwitch}"'s .stepSwitch — and only one `
2947
- + `of them fires, while the join waits for all of them. Add a step at the end of the arm, after the `
2948
- + `.stepSwitch, so its arms meet there first, or move the task out of the .parallel.`);
3367
+ const worded = fanIn();
3368
+ refuseOn(scope, worded?.task ?? switchTail.humanSwitch, new CompileRefusal('HITL_FAN_OUT_INTO_JOIN', worded?.message
3369
+ ?? `.parallel("${step.name}"): an arm sends ${t.length} alternative edges into join "${step.name}" — `
3370
+ + `among them the end of an arm of human task "${switchTail.humanSwitch}"'s .stepSwitch — and only one `
3371
+ + `of them fires, while the join waits for all of them. Add a step at the end of the arm, after the `
3372
+ + `.stepSwitch, so its arms meet there first, or move the task out of the .parallel.`, worded?.suggestion));
2949
3373
  }
2950
- if (t.length >= 2 && outcomeTail) {
2951
- throw new CompileRefusal('HITL_FAN_OUT_INTO_JOIN', `.parallel("${step.name}"): an arm sends ${t.length} alternative edges into join "${step.name}" — `
2952
- + `among them outcome edge "${outcomeTail.port}" of human task "${outcomeTail.node}" — and only one `
2953
- + `of them fires, while the join waits for all of them. Add a step at the end of the arm so its paths `
2954
- + `meet there first, or move the task out of the .parallel.`);
3374
+ else if (t.length >= 2 && outcomeTail) {
3375
+ const worded = fanIn();
3376
+ refuseOn(scope, worded?.task ?? stepOfNode(scope, outcomeTail.node), new CompileRefusal('HITL_FAN_OUT_INTO_JOIN', worded?.message
3377
+ ?? `.parallel("${step.name}"): an arm sends ${t.length} alternative edges into join "${step.name}" — `
3378
+ + `among them outcome edge "${outcomeTail.port}" of human task "${outcomeTail.node}" — and only one `
3379
+ + `of them fires, while the join waits for all of them. Add a step at the end of the arm so its paths `
3380
+ + `meet there first, or move the task out of the .parallel.`, worded?.suggestion));
2955
3381
  }
2956
3382
  armTails.push(...t);
2957
3383
  }
2958
3384
  const starved = hitlEndExits.slice(endsBefore).find((x) => x.scope === scope && x.parentId === parentId);
2959
3385
  if (starved && armTails.length > 0) {
2960
- throw new CompileRefusal('HITL_END_OUTCOME_IN_PARALLEL', `.parallel("${step.name}"): human task "${starved.step}" ${starved.why} inside an arm, and join `
2961
- + `"${step.name}" would wait forever for the path that continues. Give every outcome a path that `
2962
- + `continues to the end of the arm (drop action: 'End', add the missing .stepSwitch arm, move the `
2963
- + `.return() after the .parallel), or move the task out of the .parallel.`);
3386
+ const hazard = step.arms.flatMap((arm) => hitlEndHazards(arm)).find((h) => h.step === starved.step);
3387
+ const worded = hazard && endOutcomeRefusal(step.name, hazard);
3388
+ refuseOn(scope, starved.step, new CompileRefusal('HITL_END_OUTCOME_IN_PARALLEL', worded?.message
3389
+ ?? `.parallel("${step.name}"): human task "${starved.step}" ${starved.why} inside an arm, and join `
3390
+ + `"${step.name}" would wait forever for the path that continues. Give every outcome a path that `
3391
+ + `continues to the end of the arm (drop action: 'End', add the missing .stepSwitch arm, move the `
3392
+ + `.return() after the .parallel), or move the task out of the .parallel.`, worded?.suggestion));
2964
3393
  }
2965
3394
  // When EVERY arm is terminal — a terminate, or an End — there is nothing
2966
3395
  // left to join, and emitting a Merge would add a node with no incoming
@@ -3001,8 +3430,10 @@ export function serialize(built, opts = {}) {
3001
3430
  const loopDef = definitionFor(NODE_TYPE.loop, version);
3002
3431
  const refusal = loopVersionRefusal(step.name, o, usesBreak, loopDef);
3003
3432
  if (refusal)
3004
- throw new CompileRefusal('LOOP_VERSION_UNSUPPORTED', refusal);
3005
- const v24 = loopHasInnerHandles(version, loopDef);
3433
+ refuseOn(scope, step.name, new CompileRefusal('LOOP_VERSION_UNSUPPORTED', refusal));
3434
+ // Past that refusal (collect mode) the body is emitted with the handles it
3435
+ // asks for, so a `.break()` in it is not refused a second time.
3436
+ const v24 = loopHasInnerHandles(version, loopDef) || refusal !== undefined;
3006
3437
  const declared = loopDeclaredInputs(loopDef);
3007
3438
  const id = stepUid(scope, step.name);
3008
3439
  const node = makeNode(NODE_TYPE.loop, id, step.options?.label ?? step.label ?? step.name, { parentId, requestedVersion: version });
@@ -3073,8 +3504,7 @@ export function serialize(built, opts = {}) {
3073
3504
  // A Terminate is a `.stepToRef` target (its name is a step name), so a
3074
3505
  // second `.terminate` asking for a name this scope already holds is
3075
3506
  // refused, not renamed `<name>2`: that rename turned one Terminate two
3076
- // paths share into two Terminates with no word said (#1239). `check`
3077
- // reports it first as DUP_STEP; this is the backstop for `--no-check`.
3507
+ // paths share into two Terminates with no word said (#1239): DUP_STEP.
3078
3508
  // The remedy depends on what holds the name: another Terminate (end the
3079
3509
  // other paths at it) or a step of another kind (rename one).
3080
3510
  if (scope.stepNodeId.has(step.name)) {
@@ -3116,9 +3546,9 @@ export function serialize(built, opts = {}) {
3116
3546
  const from = tails[0] ?? (router !== undefined ? { node: router, port: step.port } : undefined);
3117
3547
  if (!from)
3118
3548
  throw new Error(`internal SDK error: .stepToRef('${step.port}', '${step.target}') has no step to leave from.`);
3119
- refuseRewire('.stepToRef', step.port, lastAction ?? '?', tails);
3549
+ refuseRewire(scope, '.stepToRef', step.port, lastAction ?? '?', tails);
3120
3550
  if (step.port === 'error') {
3121
- refuseErrorEdgeWithoutPort(from.node, lastAction ?? from.node);
3551
+ refuseErrorEdgeWithoutPort(scope, from.node, lastAction ?? from.node);
3122
3552
  const node = scope.nodes.find((n) => n.id === from.node);
3123
3553
  if (node)
3124
3554
  node.inputs = { ...(node.inputs ?? {}), errorHandlingEnabled: true };
@@ -3166,8 +3596,7 @@ export function serialize(built, opts = {}) {
3166
3596
  else {
3167
3597
  // A repeat that DIFFERS is refused, not renamed `<name>2`: the rename
3168
3598
  // turned one End shared by two paths into two Ends with no word said
3169
- // (#1231). `check` reports it first as DUP_END_NAME; this is the
3170
- // backstop for `--no-check`.
3599
+ // (#1231): DUP_END_NAME.
3171
3600
  if (prior) {
3172
3601
  throw new CompileRefusal('DUP_END_NAME', `Two .return({}, { name: '${name}' }) calls ask for the same End with different values, label, version, updates or scope. `
3173
3602
  + `To end several paths at one End, keep one .return and end the others with .stepToRef('${name}'); `
@@ -3198,10 +3627,11 @@ export function serialize(built, opts = {}) {
3198
3627
  // id the SDK makes up is the one that moves (#1253).
3199
3628
  {
3200
3629
  const rootTerminates = new Map();
3201
- const rootSteps = stepNamesOf(built.steps, [], rootTerminates);
3630
+ const rootForks = new Map();
3631
+ const rootSteps = stepNamesOf(built.steps, [], rootTerminates, rootForks);
3202
3632
  for (const ep of built.entryPoints ?? [])
3203
- stepNamesOf(ep.steps, rootSteps, rootTerminates);
3204
- claimStepIds(root, rootSteps, `flow "${built.id}"`, undefined, [], rootTerminates);
3633
+ stepNamesOf(ep.steps, rootSteps, rootTerminates, rootForks);
3634
+ claimStepIds(root, rootSteps, `flow "${built.id}"`, undefined, [], rootTerminates, rootForks);
3205
3635
  const seenEnds = new Set();
3206
3636
  const rootNodes = namedNodesOf(built.steps, undefined, seenEnds);
3207
3637
  for (const ep of built.entryPoints ?? [])
@@ -3235,75 +3665,80 @@ export function serialize(built, opts = {}) {
3235
3665
  throw new CompileRefusal('ENTRY_POINT_DUP_ID', `.entryPoint("${ep.id}"): the id collides with a step, the trigger, or another entry point.`);
3236
3666
  }
3237
3667
  takenIds.add(ep.id);
3238
- const spec = ep.trigger;
3239
- const requestedVersion = ep.options?.version;
3240
- // The author's label; makeNode falls back to the definition's label.
3241
- const epLabel = ep.options?.label;
3242
- const customEp = spec instanceof FlowTrigger && !('kind' in spec) ? spec : undefined;
3243
- const epSpec = spec;
3244
- let node;
3245
- if (customEp) {
3246
- const contribution = customEp.contribute(requestedVersion !== undefined ? { requestedVersion } : {});
3247
- const def = resolveContributionDefinition(contribution.definition, requestedVersion, `entryPoint "${ep.id}"`);
3248
- node = makeNode(def.nodeType, ep.id, ep.options?.label ?? def.display?.label ?? ep.id, { def, requestedVersion });
3249
- node.inputs = {
3250
- entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3251
- ...renderDeep(contribution.inputs ?? {}, NO_RENAME),
3252
- };
3253
- hoistContributionBindings(def, contribution.bindings, `entryPoint "${ep.id}"`);
3254
- }
3255
- else if (epSpec.kind === 'event') {
3256
- if (!opts.library)
3257
- throw new CompileRefusal('LIBRARY_REQUIRED', `onEvent() needs a library (pass --library / set $FLOW_SDK_LIBRARY_JSON).`);
3258
- const sub = epSpec.subscription;
3259
- const rc = opts.library.resolveEvent('trigger', sub.connector, sub.event, sub.version, sub.object);
3260
- refuseUnverifiedByoa(opts, 'trigger', sub);
3261
- node = makeNode(rc.nodeType, ep.id, epLabel, { def: rc.v1def, requestedVersion });
3262
- node.inputs = {
3263
- entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3264
- detail: eventDetail(rc, sub, ep.id, opts, `onEvent() entry point "${ep.id}"`),
3265
- };
3266
- const { entries } = connectionResourceBindings({
3267
- def: rc.v1def,
3268
- connectorKey: rc.connectorKey,
3269
- connectionId: resolveBinding(opts, sub.connection, 'connection', 'onEvent() entry point', eventTarget(sub)),
3270
- folderKey: resolveBinding(opts, sub.folder, 'folder', 'onEvent() entry point', eventTarget(sub)),
3271
- }, resourceBindings);
3272
- for (const b of entries)
3273
- resourceBindings.set(b.id, b);
3274
- }
3275
- else if (epSpec.kind === 'scheduled') {
3276
- node = makeNode(NODE_TYPE.scheduledTrigger, ep.id, epLabel, {
3277
- requestedVersion: scheduledVersion(epSpec.inputs, requestedVersion),
3278
- });
3279
- node.inputs = {
3280
- entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3281
- ...scheduleInputs(epSpec.inputs, ep.id),
3282
- };
3283
- }
3284
- else if (epSpec.kind === 'form') {
3285
- node = makeNode(NODE_TYPE.formTrigger, ep.id, epLabel, { requestedVersion });
3286
- node.inputs = {
3287
- entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3288
- schema: { fields: formTriggerFields(ep.inputs), outcomes: [] },
3289
- };
3290
- }
3291
- else if (epSpec.kind === 'conversation') {
3292
- node = makeNode(NODE_TYPE.conversationTrigger, ep.id, epLabel, { requestedVersion });
3293
- node.inputs = { entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`) };
3294
- }
3295
- else if (epSpec.kind === 'voice') {
3296
- node = makeNode(NODE_TYPE.voiceTrigger, ep.id, epLabel, { requestedVersion });
3297
- node.inputs = { entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`) };
3298
- }
3299
- else if (epSpec.kind === 'manual') {
3300
- node = makeNode(NODE_TYPE.trigger, ep.id, epLabel, { requestedVersion });
3301
- node.inputs = { entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`) };
3302
- }
3303
- else {
3304
- throw new CompileRefusal('TRIGGER_UNKNOWN_KIND', `.entryPoint("${ep.id}"): unsupported trigger kind ${JSON.stringify(epSpec.kind)}.`);
3305
- }
3306
- root.nodes.push(node);
3668
+ // Stood in when refused in collect mode, so its prefix and the body are still judged.
3669
+ emitRootNode(ep.id, ep.id, ep.trigger, ep.options?.version, () => {
3670
+ const spec = ep.trigger;
3671
+ const requestedVersion = ep.options?.version;
3672
+ // The author's label; makeNode falls back to the definition's label.
3673
+ const epLabel = ep.options?.label;
3674
+ const customEp = spec instanceof FlowTrigger && !('kind' in spec) ? spec : undefined;
3675
+ const epSpec = spec;
3676
+ let node;
3677
+ if (customEp) {
3678
+ const contribution = contributionOf(customEp, requestedVersion, `entryPoint "${ep.id}"`);
3679
+ const def = resolveContributionDefinition(contribution.definition, requestedVersion, `entryPoint "${ep.id}"`);
3680
+ node = makeNode(def.nodeType, ep.id, ep.options?.label ?? def.display?.label ?? ep.id, { def, requestedVersion });
3681
+ node.inputs = {
3682
+ entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3683
+ ...renderDeep(contribution.inputs ?? {}, NO_RENAME),
3684
+ };
3685
+ hoistContributionBindings(def, contribution.bindings, `entryPoint "${ep.id}"`);
3686
+ }
3687
+ else if (epSpec.kind === 'event') {
3688
+ const sub = epSpec.subscription;
3689
+ if (!opts.library)
3690
+ throw libraryRequiredForEvent(opts, sub, ep.id, `onEvent() entry point "${ep.id}"`, 'onEvent()');
3691
+ const rc = resolveEvent(opts.library, 'trigger', sub, 'trigger');
3692
+ refuseUnverifiedByoa(opts, 'trigger', sub);
3693
+ node = makeNode(rc.nodeType, ep.id, epLabel, { def: rc.v1def, requestedVersion });
3694
+ node.inputs = {
3695
+ entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3696
+ detail: eventDetail(rc, sub, ep.id, opts, `onEvent() entry point "${ep.id}"`),
3697
+ };
3698
+ const { entries } = connectionResourceBindings({
3699
+ def: rc.v1def,
3700
+ connectorKey: rc.connectorKey,
3701
+ connectionId: resolveBinding(opts, sub.connection, 'connection', 'onEvent() entry point', eventTarget(sub)),
3702
+ folderKey: resolveBinding(opts, sub.folder, 'folder', 'onEvent() entry point', eventTarget(sub)),
3703
+ }, resourceBindings);
3704
+ for (const b of entries)
3705
+ resourceBindings.set(b.id, b);
3706
+ }
3707
+ else if (epSpec.kind === 'scheduled') {
3708
+ node = makeNode(NODE_TYPE.scheduledTrigger, ep.id, epLabel, {
3709
+ requestedVersion: scheduledVersion(epSpec.inputs, requestedVersion),
3710
+ });
3711
+ node.inputs = {
3712
+ entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3713
+ ...scheduleInputs(epSpec.inputs, ep.id),
3714
+ };
3715
+ }
3716
+ else if (epSpec.kind === 'form') {
3717
+ node = makeNode(NODE_TYPE.formTrigger, ep.id, epLabel, { requestedVersion });
3718
+ node.inputs = {
3719
+ entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`),
3720
+ schema: { fields: formTriggerFields(ep.inputs), outcomes: [] },
3721
+ };
3722
+ }
3723
+ else if (epSpec.kind === 'conversation') {
3724
+ node = makeNode(NODE_TYPE.conversationTrigger, ep.id, epLabel, { requestedVersion });
3725
+ node.inputs = { entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`) };
3726
+ }
3727
+ else if (epSpec.kind === 'voice') {
3728
+ node = makeNode(NODE_TYPE.voiceTrigger, ep.id, epLabel, { requestedVersion });
3729
+ node.inputs = { entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`) };
3730
+ }
3731
+ else if (epSpec.kind === 'manual') {
3732
+ node = makeNode(NODE_TYPE.trigger, ep.id, epLabel, { requestedVersion });
3733
+ node.inputs = { entryPointId: stableId(`${built.id}:${ep.id}:entryPoint`) };
3734
+ }
3735
+ else {
3736
+ refuseUnknownTrigger(epSpec, `.entryPoint("${ep.id}")`);
3737
+ throw new Error(`internal SDK error: .entryPoint("${ep.id}"): trigger kind ${JSON.stringify(epSpec.kind)} has no emitter.`);
3738
+ }
3739
+ root.nodes.push(node);
3740
+ return node;
3741
+ });
3307
3742
  const prefixTails = emit(ep.steps, [{ node: ep.id, port: 'output' }], root);
3308
3743
  for (const t of prefixTails) {
3309
3744
  if (!sharedTarget) {
@@ -3356,7 +3791,7 @@ export function serialize(built, opts = {}) {
3356
3791
  globals.push({ id: inp.name, name: inp.name, direction: 'in', type: inp.type, triggerNodeId: ep.id, ...varMeta(inp) });
3357
3792
  }
3358
3793
  }
3359
- resolveRejoins(root, addEdge);
3794
+ resolveRejoins(root, addEdge, (from, refusal) => refuseOn(root, stepOfNode(root, from.node), refusal));
3360
3795
  assertHitlExitInvariant(root.nodes, root.edges, legacyCompletedNodes);
3361
3796
  typeAgentInputSchemas(root, globals);
3362
3797
  emitStickyNotes(built, root, claimNoteId);
@@ -3364,10 +3799,10 @@ export function serialize(built, opts = {}) {
3364
3799
  assertNodeEnvelope(root.nodes, `flow "${built.id}"`);
3365
3800
  // The FILE-FORMAT version this document declares: the author's
3366
3801
  // `.schemaVersion()` when they set one, else the profile's write floor. It is
3367
- // resolved HERE rather than in `check()` alone because the serializer must not
3368
- // stamp a version it cannot back even when the static pass was skipped
3369
- // (`compile --no-check`, `emitFlow`, a direct `serialize()`); an unwritable
3370
- // value throws with the same sentence `check()` reports.
3802
+ // resolved HERE because the serializer must not stamp a version it cannot back
3803
+ // on any path (`compile`, `emitFlow`, a direct `serialize()`); an unwritable
3804
+ // value is SCHEMA_VERSION_UNSUPPORTED, which `check` reports from its
3805
+ // serializer run.
3371
3806
  const schemaVersion = resolveSchemaVersion(FLOW_SCHEMA_VERSIONS, built.schemaVersion);
3372
3807
  const flow = {
3373
3808
  id: built.id,