@uipath/maestro-builder-sdk 6.19.0 → 6.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/api-index.md +107 -107
  2. package/dist/api-members.md +358 -356
  3. package/dist/check.js +181 -45
  4. package/dist/cli/commands/flow.js +3 -0
  5. package/dist/compile-cli.js +51 -0
  6. package/dist/core/actions.d.ts +13 -5
  7. package/dist/core/actions.js +1 -1
  8. package/dist/core/binding-identity.d.ts +22 -0
  9. package/dist/core/binding-identity.js +28 -0
  10. package/dist/core/context-index.d.ts +34 -0
  11. package/dist/core/context-index.js +38 -0
  12. package/dist/core/definitionless-node-types.d.ts +16 -0
  13. package/dist/core/definitionless-node-types.js +16 -0
  14. package/dist/core/form-field-type.d.ts +33 -0
  15. package/dist/core/form-field-type.js +30 -0
  16. package/dist/core/hitl-routing.d.ts +8 -0
  17. package/dist/core/hitl-routing.js +17 -1
  18. package/dist/core/process-resource-sort-order.d.ts +40 -0
  19. package/dist/core/process-resource-sort-order.js +44 -0
  20. package/dist/core/wire-expr.d.ts +27 -0
  21. package/dist/core/wire-expr.js +34 -0
  22. package/dist/decompile-cli.js +11 -1
  23. package/dist/decompile.d.ts +8 -3
  24. package/dist/decompile.js +587 -97
  25. package/dist/flow-expr-check.js +1 -1
  26. package/dist/flow-sdk.d.ts +33 -1
  27. package/dist/flow-sdk.js +21 -2
  28. package/dist/flow-step-tree.js +2 -1
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.js +1 -0
  31. package/dist/merge-cli.js +25 -8
  32. package/dist/merge-roundtrip.d.ts +81 -0
  33. package/dist/merge-roundtrip.js +404 -0
  34. package/dist/merge.d.ts +35 -6
  35. package/dist/merge.js +124 -24
  36. package/dist/node-placement.d.ts +93 -0
  37. package/dist/node-placement.js +163 -0
  38. package/dist/nodes/transform/flow/node-type.js +2 -1
  39. package/dist/registry/uip/pipeline-front-door.js +5 -3
  40. package/dist/serialize.js +112 -61
  41. package/package.json +2 -2
package/dist/decompile.js CHANGED
@@ -30,17 +30,21 @@
30
30
  */
31
31
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
32
32
  import { preserveSchemaVersion } from './schema-version.js';
33
- import { BUNDLED_DEFINITIONS, bundledVersions, hasBundledDefinition } from './flow-definitions.js';
33
+ import { BUNDLED_DEFINITIONS, bundledVersions } from './flow-definitions.js';
34
34
  import { DEFAULT_TRIGGER_ID, Expr } from './core/expr.js';
35
35
  import { FLOW_NODE_TYPES } from './flow-node-registry.js';
36
36
  import { commentText } from './core/comment-text.js';
37
37
  import { tsLiteral, tsNumber, tsText } from './core/ts-literal.js';
38
+ import { contextIndexSlug, parseContextIndexType } from './core/context-index.js';
39
+ import { wireExprBody } from './core/wire-expr.js';
38
40
  import { parseIxpProjectType } from './core/actions.js';
39
41
  import { readFlowGlobals } from './flow-variables.js';
40
42
  import { missingBindingLabels } from './core/binding-messages.js';
41
43
  import { prepareCommand } from './core/cli-spelling.js';
42
- import { OUTCOME_HANDLE_NODE_TYPES } from './core/hitl-routing.js';
44
+ import { OUTCOME_HANDLE_NODE_TYPES, outcomeSlug } from './core/hitl-routing.js';
45
+ import { STICKY_NOTE_TYPE } from './core/definitionless-node-types.js';
43
46
  import { eventFilterFromTreeLeaf } from './core/event-filters.js';
47
+ import { sdkFormFieldType } from './core/form-field-type.js';
44
48
  /**
45
49
  * The trigger id of the flow currently being decompiled, so a reference through a
46
50
  * RENAMED trigger is still recognized as `input('x')` rather than falling back to
@@ -79,6 +83,17 @@ let runStrict = false;
79
83
  /** `DecompileOptions.warn` for the current run, and the messages already sent to it (one line per finding). */
80
84
  let runWarn;
81
85
  let runWarned = new Set();
86
+ /**
87
+ * The agent RESOURCE nodes the agent-cluster inverter folded into an agent's
88
+ * spec (`inlineAgent({ tools, context, escalation, memory })`), mapped to the
89
+ * node id compile will mint for each (`<agent>Context`, `<agent>Tool2`, ...), or
90
+ * to `undefined` when compile will not re-emit the node at all.
91
+ *
92
+ * A folded resource is not a step, so its id is not the author's to choose:
93
+ * compile names it. Anything decompile writes that names the node, such as a
94
+ * `.layout()` key, must use the id compile will produce, not the file's.
95
+ */
96
+ let runAbsorbed = new Map();
82
97
  /** `DecompileOptions.readSidecar` for the current run. */
83
98
  let runReadSidecar;
84
99
  function warnOnce(message) {
@@ -112,19 +127,33 @@ function verClause(node) {
112
127
  + `the file's definitions[] disagrees with the instance.`);
113
128
  }
114
129
  }
115
- // Decompile preserves the file's pair (design §5.3), but `serialize` resolves an
116
- // explicit { version } against the bundle exactly (design §2.6), so a pin the
117
- // bundle lacks WILL fail `compile`; saying so here is the difference between a
118
- // loop that fails at step 2 with a hint and one that fails with a puzzle.
119
- // A registered type resolves against its registered versions, which the bundle
120
- // may exceed; every other core type against the bundle.
130
+ // A pin the SDK cannot resolve: `serialize` resolves an explicit { version }
131
+ // exactly (design §2.6) — a registered type against its registered versions,
132
+ // any other type against the bundle. Keeping that pin would emit source
133
+ // `compile` refuses (#1128: quick-form@1.6 from `node add`, @1.4 from
134
+ // `migrate`, script@1.0 from a pre-migrate file), so the source omits it: the
135
+ // node compiles at the SDK's default, and `merge --baseline` keeps the
136
+ // UNTOUCHED original node, with its own version and definition, verbatim
137
+ // (`nodeUnchanged` ignores typeVersion). Interim: a node the author EDITS is
138
+ // re-emitted at the default — the root fix is for compile to accept the
139
+ // definition the file carries.
140
+ //
141
+ // A type with no SDK-known version at all (a connector, a published-resource
142
+ // `uipath.core.*` node, a rawNode) resolves its version elsewhere, so its pin
143
+ // is kept.
121
144
  const registered = FLOW_NODE_TYPES.byTypeName(node.type);
122
- const shipped = registered ? Object.keys(registered.type.versions)
123
- : node.type.startsWith('core.') && !hasBundledDefinition(node.type, tv) ? bundledVersions(node.type) : undefined;
124
- if (shipped && !shipped.includes(tv)) {
125
- warnOnce(`node ${tsLiteral(node.id)} pins ${node.type}@${tv}, but this SDK ${registered ? 'registers' : 'bundles'} ${node.type}@${shipped.join(', ') || '<none>'} only; `
126
- + `\`compile\` will refuse the emitted { version: ${tsLiteral(tv)} } (versions resolve exactly, flow-builder-sdk#71). `
127
- + `The source keeps the file's pair; to recompile, drop that { version } and the node takes the SDK's pinned version.`);
145
+ const shipped = registered ? Object.keys(registered.type.versions) : bundledVersions(node.type);
146
+ if (shipped.length > 0 && !shipped.includes(tv)) {
147
+ const known = `${node.type}@${shipped.join(', ')}`;
148
+ if (runStrict) {
149
+ // Strict never changes a node's version behind the author's back.
150
+ throw new Error(`decompile: node "${node.id}" pins ${node.type}@${tv}, which this SDK cannot resolve (it ${registered ? 'registers' : 'bundles'} `
151
+ + `${known}); compile would refuse the pin, and omitting it changes the node's version.`);
152
+ }
153
+ warnOnce(`node ${tsLiteral(node.id)} pins ${node.type}@${tv}, but this SDK ${registered ? 'registers' : 'bundles'} ${known} only, `
154
+ + `so the source omits that { version } and \`compile\` emits the SDK's default (versions resolve exactly, flow-builder-sdk#71). `
155
+ + `\`merge --baseline\` keeps the node at ${tv} if you leave it untouched; if you edit it, the merged node moves to the SDK's version.`);
156
+ return '';
128
157
  }
129
158
  return `version: ${tsLiteral(tv)}`;
130
159
  }
@@ -393,7 +422,19 @@ class Graph {
393
422
  byId = new Map();
394
423
  out = new Map();
395
424
  inc = new Map();
425
+ /**
426
+ * Edge accounting (#1125): every edge the source carries — walked into by a
427
+ * construct, or one serialize recreates on its own — is added here as the walk
428
+ * emits it. What is left at the end has no spelling in the emitted source, so
429
+ * recompiling would drop it; `droppedEdgesSeg` (at the site) and
430
+ * `sweepDroppedEdges` (edges no walk reached) say so instead of letting the
431
+ * round trip lose it silently. The edge counterpart of the node check
432
+ * (`detachedTopLevelNodes`).
433
+ */
434
+ accounted = new Set();
435
+ edges;
396
436
  constructor(nodes, edges) {
437
+ this.edges = edges;
397
438
  for (const n of nodes)
398
439
  this.byId.set(n.id, n);
399
440
  for (const e of edges) {
@@ -504,9 +545,8 @@ function specValue(raw) {
504
545
  * Print a node whose type is registered (flow-node-registry.ts): the node type
505
546
  * deserializes it to its spec and prints the factory call. A node at a version
506
547
  * the type does not register is lifted by its default version's implementation —
507
- * as every built-in family lifts any version today — and keeps its own
508
- * `{ version }` on the step, which compile then refuses; `verClause` warns at
509
- * decompile time that it will. `undefined` when the type is not registered, or the node type declines
548
+ * as every built-in family lifts any version — and `verClause` omits a
549
+ * `{ version }` the type does not register, with a warning. `undefined` when the type is not registered, or the node type declines
510
550
  * the node; the caller's branches take it from there.
511
551
  */
512
552
  function emitRegisteredSpec(node, imp, inputNames) {
@@ -780,7 +820,7 @@ function emitHttp(node, managed, imp, inputNames) {
780
820
  const branchesSrc = branchRows.length
781
821
  ? `[${branchRows.map((b) => {
782
822
  const cond = unwrapSource(b.conditionExpression) ?? '';
783
- const expr = emitBareExpr(cond.startsWith('=js:') ? cond.slice(4) : cond, imp, inputNames);
823
+ const expr = emitBareExpr(wireExprBody(cond), imp, inputNames);
784
824
  return `{ name: ${tsText(b.name ?? b.id)}, condition: ${expr} }`;
785
825
  }).join(', ')}]`
786
826
  : undefined;
@@ -938,6 +978,72 @@ function sidecarAgentInputs(node) {
938
978
  ...(isVoice && settings.voice && typeof settings.voice === 'object' ? { voice: settings.voice } : {}),
939
979
  };
940
980
  }
981
+ /**
982
+ * Where a context-index resource node keeps its identity, read in the order
983
+ * that holds for every writer.
984
+ *
985
+ * SDK compile writes `indexId` / `indexName` / `folderPath` onto the node. The
986
+ * canvas and the CLI do not: their node carries only `inputs.source`, and the
987
+ * identity lives in two other places.
988
+ *
989
+ * - The node TYPE, `…context.index.<slug>.<uuid>`: the uuid is the index id
990
+ * (the `ContextIndexRef.id` JSDoc says to read it there).
991
+ * - The resource sidecar `<agent source>/resources/<inputs.source>/resource.json`,
992
+ * whose `name` is the index's real name (the one the slug is made from) and
993
+ * whose `folderPath` is its folder. `display.label` is only what the canvas
994
+ * shows, and a canvas rename changes it.
995
+ *
996
+ * So the id comes from the type (then `inputs.indexId`). The name is the first
997
+ * of `inputs.indexName`, sidecar `name`, sidecar `indexName`, label that slugs to
998
+ * the type's slug, else the first one present in that order. Compile derives
999
+ * the type from `slug(name)` + id, so when the chosen name does not slug back to
1000
+ * the type's slug the recompiled node gets a different type, and that is warned.
1001
+ */
1002
+ function contextIndexIdentity(agent, c, rPlain) {
1003
+ const fromType = parseContextIndexType(c.type);
1004
+ const sidecar = contextResourceSidecar(agent, c);
1005
+ const sidecarText = (k) => typeof sidecar?.[k] === 'string' && sidecar[k] !== '' ? sidecar[k] : undefined;
1006
+ const label = typeof c.display?.label === 'string' && c.display.label !== '' ? c.display.label : undefined;
1007
+ // The canvas can set both sidecar `name` and `indexName`, and they can differ.
1008
+ // The one that slugs to the type's slug is the one the type was built from, so
1009
+ // it wins; the fixed order is the fallback when none matches.
1010
+ const candidates = [rPlain(c, 'indexName'), sidecarText('name'), sidecarText('indexName'), label]
1011
+ .filter((n) => n !== undefined);
1012
+ const name = (fromType?.slug ? candidates.find((n) => contextIndexSlug(n) === fromType.slug) : undefined)
1013
+ ?? candidates[0] ?? '';
1014
+ const id = (fromType?.id || undefined) ?? rPlain(c, 'indexId') ?? '';
1015
+ if (fromType !== undefined && fromType.slug !== '' && contextIndexSlug(name) !== fromType.slug) {
1016
+ warnOnce(`context index "${c.id}" on agent "${agent.id}": its node type names the index "${fromType.slug}", `
1017
+ + `but the name decompile recovered, ${JSON.stringify(name)}, slugs to "${contextIndexSlug(name)}", so compile `
1018
+ + `will write a different node type. Set \`name\` to the index's real name`
1019
+ + (sidecar === undefined ? ', or keep the resources/ folder beside the .flow so decompile can read it.' : '.'));
1020
+ }
1021
+ return { name, id, folderPath: rPlain(c, 'folderPath') ?? sidecarText('folderPath') };
1022
+ }
1023
+ /**
1024
+ * A context resource's `resource.json` sidecar, parsed; `undefined` when it is
1025
+ * not beside the `.flow`, or (with a warning) when it is not a JSON object.
1026
+ */
1027
+ function contextResourceSidecar(agent, c) {
1028
+ const agentSource = unwrapSource(agent.inputs?.source);
1029
+ const resourceSource = unwrapSource(c.inputs?.source);
1030
+ if (!agentSource || !resourceSource)
1031
+ return undefined;
1032
+ const path = `${agentSource}/resources/${resourceSource}/resource.json`;
1033
+ const raw = runReadSidecar?.(path);
1034
+ if (raw === undefined)
1035
+ return undefined;
1036
+ try {
1037
+ const json = JSON.parse(raw);
1038
+ if (json && typeof json === 'object' && !Array.isArray(json))
1039
+ return json;
1040
+ }
1041
+ catch {
1042
+ // Reported below with the non-object case.
1043
+ }
1044
+ warnOnce(`context index "${c.id}": ${path} is not valid JSON, so its name and folder decompile from the node.`);
1045
+ return undefined;
1046
+ }
941
1047
  /**
942
1048
  * An AGENT and its attached resources — the inline autonomous agent, the
943
1049
  * conversational agent, and the voice agent.
@@ -991,18 +1097,47 @@ function emitAgentCluster(node, imp, inputNames, graph) {
991
1097
  .slice()
992
1098
  .sort((a, b) => vars.findIndex((v) => v.id === a.flatName) - vars.findIndex((v) => v.id === b.flatName))
993
1099
  .map((r) => {
994
- const js = r.binding.startsWith('=js:') ? r.binding.slice(4) : r.binding;
995
- return [r.flatName, emitBareExpr(js, imp, inputNames)];
1100
+ return [r.flatName, emitBareExpr(wireExprBody(r.binding), imp, inputNames)];
996
1101
  });
997
1102
  const outVars = (Array.isArray(i.agentOutputVariables) ? i.agentOutputVariables : []);
998
1103
  const returns = outVars.length
999
1104
  ? objLit(outVars.map((v) => [String(v.id ?? ''), tsLiteral(String(v.type ?? 'string'))]))
1000
1105
  : undefined;
1001
- // The resources, from the agent's own artifact edges.
1002
- const resource = (port) => (graph?.outEdges(node.id) ?? [])
1003
- .filter((e) => e.sourcePort === port)
1004
- .map((e) => graph.byId.get(e.targetNodeId))
1005
- .filter((n) => n !== undefined);
1106
+ // The resources, from the agent's own artifact edges. Each edge read here is
1107
+ // carried by the agent spec (edge accounting, #1125); `limit` is for a port
1108
+ // the spec carries only one resource on.
1109
+ const resource = (port, limit = Infinity) => {
1110
+ const found = [];
1111
+ for (const e of graph?.outEdges(node.id) ?? []) {
1112
+ if (e.sourcePort !== port || found.length >= limit)
1113
+ continue;
1114
+ const n = graph.byId.get(e.targetNodeId);
1115
+ if (n === undefined)
1116
+ continue;
1117
+ graph.accounted.add(e);
1118
+ found.push(n);
1119
+ }
1120
+ return found;
1121
+ };
1122
+ // Record what compile will call each folded resource node. Serialize mints
1123
+ // `uid(scope, \`${step.name}<Kind>\`)` per resource, in spec order, and the
1124
+ // spec order is this edge order: the first is `<agent><Kind>`, the n-th
1125
+ // `<agent><Kind><n>` (claimId's dedupe). Only the first memory is recovered,
1126
+ // so every further memory maps to `undefined`.
1127
+ // This reads the edges directly, not through `resource()`: it must see EVERY
1128
+ // artifact edge, including ones the spec does not carry, and it must not
1129
+ // account for any edge.
1130
+ for (const [port, kind, emittedCount] of [
1131
+ ['context', 'Context', Infinity], ['tool', 'Tool', Infinity],
1132
+ ['escalation', 'Escalation', Infinity], ['memory', 'Memory', 1],
1133
+ ]) {
1134
+ const base = `${node.id}${kind}`.replace(/[^A-Za-z0-9_]/g, '_');
1135
+ (graph?.outEdges(node.id) ?? [])
1136
+ .filter((e) => e.sourcePort === port && graph.byId.has(e.targetNodeId))
1137
+ .forEach((e, k) => {
1138
+ runAbsorbed.set(e.targetNodeId, k >= emittedCount ? undefined : k === 0 ? base : `${base}${k + 1}`);
1139
+ });
1140
+ }
1006
1141
  const rInputs = (n) => (n.inputs ?? {});
1007
1142
  const rPlain = (n, k) => {
1008
1143
  const raw = unwrapSource(rInputs(n)[k]);
@@ -1113,10 +1248,11 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1113
1248
  : typeof q?.textValue === 'string' && q.textValue !== '' ? q.textValue
1114
1249
  : typeof q === 'string' && q !== '' ? q
1115
1250
  : undefined;
1251
+ const { name, id, folderPath } = contextIndexIdentity(node, c, rPlain);
1116
1252
  return opts([
1117
- ['name', tsLiteral(rPlain(c, 'indexName') ?? String(c.display?.label ?? ''))],
1118
- ['id', tsLiteral(rPlain(c, 'indexId') ?? '')],
1119
- ['folderPath', rPlain(c, 'folderPath') !== undefined ? tsLiteral(rPlain(c, 'folderPath')) : undefined],
1253
+ ['name', tsLiteral(name)],
1254
+ ['id', tsLiteral(id)],
1255
+ ['folderPath', folderPath !== undefined ? tsLiteral(folderPath) : undefined],
1120
1256
  ['folderKey', rPlain(c, 'folderKey') !== undefined ? tsLiteral(rPlain(c, 'folderKey')) : undefined],
1121
1257
  ['query', queryText !== undefined ? tsLiteral(queryText) : undefined],
1122
1258
  ]);
@@ -1139,7 +1275,7 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1139
1275
  ['description', rPlain(e, 'description') !== undefined ? tsLiteral(rPlain(e, 'description')) : undefined],
1140
1276
  ['fields', rows.length ? `[${rows.map((f) => opts([
1141
1277
  ['id', tsText(f.id)],
1142
- ['type', tsLiteral(String(f.type ?? 'text'))],
1278
+ ['type', tsLiteral(sdkFormFieldType(f.type ?? 'text') ?? String(f.type))],
1143
1279
  ['direction', tsLiteral(String(f.direction ?? 'output'))],
1144
1280
  ])).join(', ')}]` : undefined],
1145
1281
  ['app', app === undefined ? undefined : opts([
@@ -1154,7 +1290,7 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1154
1290
  ['priority', typeof extra.priority === 'string' && extra.priority !== 'medium' ? tsLiteral(extra.priority) : undefined],
1155
1291
  ]);
1156
1292
  });
1157
- const mem = resource('memory')[0];
1293
+ const mem = resource('memory', 1)[0];
1158
1294
  const memorySrc = mem === undefined ? undefined : (() => {
1159
1295
  // The memory's id is the node type's tail; the def carries it too.
1160
1296
  const tail = mem.type.slice('uipath.agent.resource.memory.'.length);
@@ -1189,8 +1325,9 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1189
1325
  ['inputs', inputRows.length ? objLit(inputRows) : undefined],
1190
1326
  ['callContext', (() => {
1191
1327
  const raw = plain(i.callContext) ?? '';
1192
- const js = raw.startsWith('=js:') ? raw.slice(4) : raw;
1193
- return js.startsWith('$vars.') ? emitBareExpr(js, imp, inputNames) : tsLiteral(js);
1328
+ const js = wireExprBody(raw);
1329
+ // Anything but a `$vars.` reference stays the wire text verbatim.
1330
+ return js.startsWith('$vars.') ? emitBareExpr(js, imp, inputNames) : tsLiteral(raw);
1194
1331
  })()],
1195
1332
  ['voice', voiceSrc],
1196
1333
  ['maxIterations', num(i.maxIterations)],
@@ -1292,7 +1429,7 @@ function emitHitl(node, imp, inputNames, graph) {
1292
1429
  const raw = plain(v);
1293
1430
  if (raw === undefined)
1294
1431
  return undefined;
1295
- const js = raw.startsWith('=js:') ? raw.slice(4) : raw;
1432
+ const js = wireExprBody(raw);
1296
1433
  return emitBareExpr(js.startsWith('vars.') ? `$${js}` : js, imp, inputNames);
1297
1434
  };
1298
1435
  const rows = (Array.isArray(schema.fields) ? schema.fields : []);
@@ -1303,17 +1440,29 @@ function emitHitl(node, imp, inputNames, graph) {
1303
1440
  ['id', tsLiteral(id)],
1304
1441
  // serialize defaults a row's label to its id.
1305
1442
  ['label', typeof f.label === 'string' && f.label !== id ? tsLiteral(f.label) : undefined],
1306
- ['type', tsLiteral(String(f.type ?? 'text'))],
1443
+ // The product's own spelling (`"string"`, `"datetime"`, …) → the SDK's (#1143).
1444
+ ['type', tsLiteral(sdkFormFieldType(f.type ?? 'text') ?? String(f.type))],
1307
1445
  ['direction', tsLiteral(direction)],
1308
1446
  ['value', direction === 'input' || direction === 'inOut' ? bindingExpr(f.binding) : undefined],
1309
1447
  ['required', f.required === true ? 'true' : undefined],
1310
1448
  ]);
1311
1449
  });
1312
1450
  const outcomeRows = (Array.isArray(schema.outcomes) ? schema.outcomes : []);
1313
- const outcomes = outcomeRows.map((o) => {
1451
+ const outcomes = outcomeRows.map((o, idx) => {
1314
1452
  const name = String(o.name ?? '');
1315
- // A bare string is the shorthand; only `action: 'End'` needs the object.
1316
- return o.action === 'End' ? opts([['name', tsLiteral(name)], ['action', tsLiteral('End')]]) : tsLiteral(name);
1453
+ // The file's id comes back only when serialize would not derive it from the
1454
+ // name: a designer-drawn task's ids (`outcome-1778015958049`) are not slugs,
1455
+ // and merge keeps the ORIGINAL node, so recompiled edges must use its ids
1456
+ // (#1130). An SDK-written task's ids are slugs and stay implicit.
1457
+ const id = typeof o.id === 'string' && o.id !== '' && o.id !== outcomeSlug(name, idx) ? o.id : undefined;
1458
+ // A bare string is the shorthand; `action: 'End'` or a kept id needs the object.
1459
+ return o.action === 'End' || id !== undefined
1460
+ ? opts([
1461
+ ['name', tsLiteral(name)],
1462
+ ['action', o.action === 'End' ? tsLiteral('End') : undefined],
1463
+ ['id', id !== undefined ? tsLiteral(id) : undefined],
1464
+ ])
1465
+ : tsLiteral(name);
1317
1466
  });
1318
1467
  // Delivery: only an override comes back. The definition's own default is what
1319
1468
  // serialize emits when the author wrote nothing.
@@ -1344,7 +1493,7 @@ function emitHitl(node, imp, inputNames, graph) {
1344
1493
  const bindings = (i.appInputBindings ?? {});
1345
1494
  const appInputs = Object.keys(bindings).length === 0 ? undefined : objLit(Object.entries(bindings).map(([k, v]) => {
1346
1495
  const raw = String(v ?? '');
1347
- return [k, raw.startsWith('=') ? emitBareExpr(`$${raw.slice(1)}`, imp, inputNames) : tsLiteral(raw)];
1496
+ return [k, raw.startsWith('=') ? emitBareExpr(wireExprBody(raw), imp, inputNames) : tsLiteral(raw)];
1348
1497
  }));
1349
1498
  const appSrc = app === undefined ? undefined : opts([
1350
1499
  // `displayName` is serialize's echo of `name`; only `name` is authored.
@@ -1355,13 +1504,14 @@ function emitHitl(node, imp, inputNames, graph) {
1355
1504
  : typeof app.appSystemName === 'string' ? tsLiteral(app.appSystemName) : undefined],
1356
1505
  ['inputs', appInputs],
1357
1506
  ]);
1358
- // The validation station's own inputs ride as PLAIN `$vars.…` paths.
1507
+ // The validation station's own inputs ride as PLAIN `$vars.…` paths. Anything
1508
+ // but a `$vars.` reference stays the wire text verbatim.
1359
1509
  const stationExpr = (v) => {
1360
1510
  const raw = plain(v);
1361
1511
  if (raw === undefined)
1362
1512
  return undefined;
1363
- const js = raw.startsWith('=js:') ? raw.slice(4) : raw;
1364
- return js.startsWith('$vars.') ? emitBareExpr(js, imp, inputNames) : tsLiteral(js);
1513
+ const js = wireExprBody(raw);
1514
+ return js.startsWith('$vars.') ? emitBareExpr(js, imp, inputNames) : tsLiteral(raw);
1365
1515
  };
1366
1516
  const render = plain(i.validationRenderType);
1367
1517
  const documentSrc = node.type !== T.hitlDocValidation ? undefined : opts([
@@ -1839,7 +1989,7 @@ function scopeSucc(graph, id, scope) {
1839
1989
  * a fork. Returns a map from node id → its ipdom (`EXIT` when its only join is
1840
1990
  * the scope exit).
1841
1991
  */
1842
- function postDominators(entry, scope, graph) {
1992
+ function postDominators(entry, scope, graph, container) {
1843
1993
  // Forward successors within scope (EXIT for terminals / scope-exits), over the
1844
1994
  // subgraph reachable from `entry`.
1845
1995
  const succ = new Map();
@@ -1853,7 +2003,13 @@ function postDominators(entry, scope, graph) {
1853
2003
  continue;
1854
2004
  seen.add(n);
1855
2005
  const s = scopeSucc(graph, n, scope);
1856
- const ss = s.length ? s : [EXIT];
2006
+ // An edge back into the enclosing container (`continue` / `break` /
2007
+ // `loopBack`) leaves the body: it is a path to EXIT like a terminal is, so
2008
+ // a branch with one empty arm does not "reconverge" at the other arm's
2009
+ // first step.
2010
+ const leaves = container !== undefined
2011
+ && graph.successorEdges(n).some((e) => e.targetNodeId === container);
2012
+ const ss = s.length === 0 ? [EXIT] : leaves ? [...s, EXIT] : s;
1857
2013
  succ.set(n, ss);
1858
2014
  for (const t of ss) {
1859
2015
  addP(n, t);
@@ -1912,9 +2068,164 @@ function postDominators(entry, scope, graph) {
1912
2068
  }
1913
2069
  return ipdom;
1914
2070
  }
1915
- /** The target of the first out-edge of `id` on port `port` (in the whole graph). */
2071
+ /**
2072
+ * The target of `id`'s first edge on `port`. Every caller walks into what it
2073
+ * returns, so the edge counts as emitted (edge accounting, #1125). A second edge
2074
+ * on the same port is not carried and stays unaccounted.
2075
+ */
1916
2076
  function portTarget(graph, id, port) {
1917
- return graph.outEdges(id).find((e) => e.sourcePort === port)?.targetNodeId;
2077
+ const edge = graph.outEdges(id).find((e) => e.sourcePort === port);
2078
+ if (edge)
2079
+ graph.accounted.add(edge);
2080
+ return edge?.targetNodeId;
2081
+ }
2082
+ /** `id`'s first edge on `port`, for a caller that walks it with `edgeRegion`. */
2083
+ function portEdge(graph, id, port) {
2084
+ return graph.outEdges(id).find((e) => e.sourcePort === port);
2085
+ }
2086
+ /**
2087
+ * The region an arm, handler or port list starts with `edge` — accounting for
2088
+ * the edge (#1125). An edge straight back into the enclosing container (an empty
2089
+ * arm inside a loop body, wired to the container's `continue`) is no region at all,
2090
+ * not `.stepToRef(<container>)`, which compiles to the container's `input` port
2091
+ * and re-enters the loop. On the body's main path that edge is one serialize
2092
+ * recreates for an empty arm; on a side path (`refInto` set: a handler, a port
2093
+ * list, an arm inside one) it is `.continue()`. An edge straight to the
2094
+ * container's `break` handle is `.break()`.
2095
+ */
2096
+ function edgeRegion(edge, stop, scope, ipdom, ctx, refInto) {
2097
+ if (edge === undefined)
2098
+ return [];
2099
+ if (ctx.breakInto !== undefined && edge.targetNodeId === ctx.breakInto) {
2100
+ // An arm that breaks straight away: `.break()` on its own recreates the edge.
2101
+ if (edge.targetPort === 'break') {
2102
+ ctx.graph.accounted.add(edge);
2103
+ return ['.break()'];
2104
+ }
2105
+ if (edge.targetPort === 'continue' || edge.targetPort === 'loopBack') {
2106
+ ctx.graph.accounted.add(edge);
2107
+ return refInto === undefined ? [] : ['.continue()'];
2108
+ }
2109
+ return [];
2110
+ }
2111
+ ctx.graph.accounted.add(edge);
2112
+ return region(edge.targetNodeId, stop, scope, ipdom, ctx, refInto);
2113
+ }
2114
+ /** Account for the successor edges of `from` that a walk followed to `to` (#1125). */
2115
+ function follow(graph, from, to) {
2116
+ if (to === undefined)
2117
+ return undefined;
2118
+ for (const e of graph.successorEdges(from)) {
2119
+ if (e.targetNodeId === to && e.targetPort !== 'loopBack')
2120
+ graph.accounted.add(e);
2121
+ }
2122
+ return to;
2123
+ }
2124
+ /**
2125
+ * Account for the edges serialize recreates on its own when `node` ends a path:
2126
+ * a loop or do-while body tail on the MAIN path of the body goes back to the
2127
+ * container's `continue` handle (`loopBack` on a 1.0 loop) without any source
2128
+ * spelling it. A tail on a side path — an `.onError` handler, an HTTP branch,
2129
+ * a non-primary outcome (`refInto` set) — does NOT: serialize caps it with an
2130
+ * End of its own, so its edge into the container is left unaccounted.
2131
+ */
2132
+ function accountImplicitTail(node, ctx, refInto) {
2133
+ if (ctx.breakInto === undefined || refInto !== undefined)
2134
+ return;
2135
+ for (const e of ctx.graph.outEdges(node.id)) {
2136
+ if (e.targetNodeId === ctx.breakInto && (e.targetPort === 'continue' || e.targetPort === 'loopBack')) {
2137
+ ctx.graph.accounted.add(e);
2138
+ }
2139
+ }
2140
+ }
2141
+ /**
2142
+ * Where the walk goes after `from` leaves on `port` — a container's `success`,
2143
+ * a join's `output`. That edge can lead straight back into the ENCLOSING
2144
+ * container (the body's tail: `inner@success -> outer@continue`). Walking into
2145
+ * the container would emit `.stepToRef(<container>)`, which compiles to its
2146
+ * `input` port and re-enters the loop, so it ends the path instead:
2147
+ *
2148
+ * - into `<container>@break`: `{ brk: true }`, the caller emits `.break()`;
2149
+ * - into `<container>@continue` / `@loopBack`: on the main path no cursor,
2150
+ * and the edge is one serialize recreates (`accountImplicitTail`); on a
2151
+ * side path `{ cont: true }`, the caller emits `.continue()`;
2152
+ * - anything else: the target, with the edge accounted (as `portTarget`).
2153
+ */
2154
+ function continueOn(from, port, ctx, refInto) {
2155
+ const edge = portEdge(ctx.graph, from.id, port);
2156
+ if (edge !== undefined && ctx.breakInto !== undefined && edge.targetNodeId === ctx.breakInto) {
2157
+ if (edge.targetPort === 'break') {
2158
+ ctx.graph.accounted.add(edge);
2159
+ return { brk: true };
2160
+ }
2161
+ if (edge.targetPort === 'continue' || edge.targetPort === 'loopBack') {
2162
+ if (refInto !== undefined) {
2163
+ // A side path has no implicit tail: it goes on with `.continue()`.
2164
+ ctx.graph.accounted.add(edge);
2165
+ return { cont: true };
2166
+ }
2167
+ accountImplicitTail(from, ctx, refInto);
2168
+ return {};
2169
+ }
2170
+ }
2171
+ if (edge === undefined) {
2172
+ accountImplicitTail(from, ctx, refInto);
2173
+ return {};
2174
+ }
2175
+ ctx.graph.accounted.add(edge);
2176
+ return { cursor: edge.targetNodeId };
2177
+ }
2178
+ /** `<source>@<port> -> <target>@<port>` — how an edge is named in a message. */
2179
+ function edgeName(e) {
2180
+ return `${e.sourceNodeId ?? '?'}@${e.sourcePort ?? '?'} -> ${e.targetNodeId ?? '?'}@${e.targetPort ?? '?'}`;
2181
+ }
2182
+ /** The edges this run could not carry into the source, for the strict-mode refusal. */
2183
+ let runDropped = [];
2184
+ /** The warning for an edge with no spelling in the emitted source. */
2185
+ function droppedEdgeMessage(e) {
2186
+ return `edge ${edgeName(e)} has no spelling in the decompiled source, so recompiling drops it `
2187
+ + '(and merge writes that loss into the original). If this edge matters to your edit, '
2188
+ + 'change the .flow JSON directly instead of round-tripping it through the source.';
2189
+ }
2190
+ /**
2191
+ * The out-edges of `node` that nothing emitted for it accounts for — called
2192
+ * once the walk has finished with the node, so every construct it heads has had
2193
+ * its chance to claim them. Each is warned once, remembered for strict mode,
2194
+ * and returned as a `/* TODO *\/` segment for the site.
2195
+ */
2196
+ function droppedEdgesSeg(node, ctx) {
2197
+ const dropped = ctx.graph.outEdges(node.id).filter((e) => !ctx.graph.accounted.has(e));
2198
+ if (dropped.length === 0)
2199
+ return undefined;
2200
+ for (const e of dropped) {
2201
+ ctx.graph.accounted.add(e);
2202
+ runDropped.push(e);
2203
+ warnOnce(droppedEdgeMessage(e));
2204
+ }
2205
+ return `/* TODO: ${commentText(`edge(s) ${dropped.map(edgeName).join(', ')} are NOT in this source; recompiling drops them.`)} */`;
2206
+ }
2207
+ /** Push `node`'s dropped-edge marker (if any) onto `segs`. */
2208
+ function flagDropped(node, ctx, segs) {
2209
+ const seg = droppedEdgesSeg(node, ctx);
2210
+ if (seg !== undefined)
2211
+ segs.push(seg);
2212
+ }
2213
+ /**
2214
+ * The edges no walk reached at all (their source was never emitted, typically
2215
+ * because the only path to it was itself dropped), as `// TODO` lines. Edges on
2216
+ * a detached node are left out: node accounting already reported that node.
2217
+ */
2218
+ function sweepDroppedEdges(graph, skip) {
2219
+ const lines = [];
2220
+ for (const e of graph.edges) {
2221
+ if (graph.accounted.has(e) || skip.has(e.sourceNodeId) || skip.has(e.targetNodeId))
2222
+ continue;
2223
+ graph.accounted.add(e);
2224
+ runDropped.push(e);
2225
+ warnOnce(droppedEdgeMessage(e));
2226
+ lines.push(`// TODO: ${commentText(`edge ${edgeName(e)} is NOT in this source; recompiling drops it.`)}`);
2227
+ }
2228
+ return lines;
1918
2229
  }
1919
2230
  /** Re-indent every non-empty line of a (possibly multi-line) segment by `pad`. */
1920
2231
  function reindent(seg, pad) {
@@ -2015,7 +2326,7 @@ function errorHandlerSeg(nodeId, succ, scope, ipdom, ctx) {
2015
2326
  if (!errEdge)
2016
2327
  return undefined;
2017
2328
  const mainReach = forwardReach(succ, scope, ctx.graph);
2018
- const handlerSegs = region(errEdge.targetNodeId, EXIT, scope, ipdom, ctx, mainReach);
2329
+ const handlerSegs = edgeRegion(errEdge, EXIT, scope, ipdom, ctx, mainReach);
2019
2330
  if (handlerSegs.length === 0)
2020
2331
  handlerSegs.push('.return({})');
2021
2332
  return `.onError(${armCb('h', handlerSegs)})`;
@@ -2050,34 +2361,45 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2050
2361
  ctx.emitted.add(cursor);
2051
2362
  if (node.type === T.end) {
2052
2363
  segs.push(returnSeg(node, ctx));
2364
+ flagDropped(node, ctx, segs);
2053
2365
  return segs;
2054
2366
  }
2055
2367
  if (node.type === T.terminate) {
2056
2368
  segs.push(`.terminate(${tsLiteral(node.id)}, undefined${optsArg(node, ctx)})`);
2369
+ flagDropped(node, ctx, segs);
2057
2370
  return segs;
2058
2371
  }
2059
2372
  if (node.type === T.merge) {
2060
2373
  // A bare merge reached on the main path (its fork was not detected as a
2061
2374
  // parallel). Skip it and continue from its output — best effort.
2062
- cursor = portTarget(ctx.graph, node.id, 'output');
2375
+ const next = continueOn(node, 'output', ctx, refInto);
2376
+ if (next.brk || next.cont) {
2377
+ segs.push(next.brk ? '.break()' : '.continue()');
2378
+ flagDropped(node, ctx, segs);
2379
+ return segs;
2380
+ }
2381
+ cursor = next.cursor;
2382
+ flagDropped(node, ctx, segs);
2063
2383
  continue;
2064
2384
  }
2065
2385
  if (node.type === T.decision) {
2066
2386
  const r = ipdom.get(node.id) ?? stop;
2067
2387
  const cond = emitBareExpr(String((node.inputs ?? {}).expression ?? ''), ctx.imp, ctx.inputNames);
2068
- const thenSegs = region(portTarget(ctx.graph, node.id, 'true'), r, scope, ipdom, ctx, refInto);
2069
- const elseSegs = region(portTarget(ctx.graph, node.id, 'false'), r, scope, ipdom, ctx, refInto);
2388
+ const thenSegs = edgeRegion(portEdge(ctx.graph, node.id, 'true'), r, scope, ipdom, ctx, refInto);
2389
+ const elseSegs = edgeRegion(portEdge(ctx.graph, node.id, 'false'), r, scope, ipdom, ctx, refInto);
2070
2390
  const labels = armLabels(node);
2071
2391
  if (labels.trueLabel)
2072
2392
  thenSegs.unshift(`.label(${tsLiteral(labels.trueLabel)})`);
2073
2393
  if (labels.falseLabel)
2074
2394
  elseSegs.unshift(`.label(${tsLiteral(labels.falseLabel)})`);
2075
2395
  segs.push(`.branch(${tsLiteral(node.id)}, ${cond},\n${reindent(armCb('t', thenSegs), ' ')},\n${reindent(armCb('e', elseSegs), ' ')}${verArg(node)})`);
2396
+ flagDropped(node, ctx, segs);
2076
2397
  cursor = r === EXIT ? undefined : r;
2077
2398
  continue;
2078
2399
  }
2079
2400
  if (node.type === T.switch) {
2080
2401
  segs.push(emitSwitch(node, ipdom, scope, ctx, stop, refInto));
2402
+ flagDropped(node, ctx, segs);
2081
2403
  const r = ipdom.get(node.id) ?? stop;
2082
2404
  cursor = r === EXIT ? undefined : r;
2083
2405
  continue;
@@ -2089,7 +2411,7 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2089
2411
  // from the inner `start` handle. Break edges (body node -> container's
2090
2412
  // `break` target) decompile to `.break()` inside the body region.
2091
2413
  const bodyEntry = portTarget(ctx.graph, node.id, 'start') ?? portTarget(ctx.graph, node.id, 'output');
2092
- const bodyIpdom = bodyEntry ? postDominators(bodyEntry, bodyScope, ctx.graph) : new Map();
2414
+ const bodyIpdom = bodyEntry ? postDominators(bodyEntry, bodyScope, ctx.graph, node.id) : new Map();
2093
2415
  const prevBreak = ctx.breakInto;
2094
2416
  ctx.breakInto = node.id;
2095
2417
  const bodySegs = region(bodyEntry, EXIT, bodyScope, bodyIpdom, ctx);
@@ -2101,7 +2423,7 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2101
2423
  extra.push('parallel: true');
2102
2424
  const completion = unwrapSource(inputs.completionCondition);
2103
2425
  if (typeof completion === 'string' && completion.length) {
2104
- extra.push(`completionCondition: ${emitBareExpr(completion.startsWith('=js:') ? completion.slice(4) : completion, ctx.imp, ctx.inputNames)}`);
2426
+ extra.push(`completionCondition: ${emitBareExpr(wireExprBody(completion), ctx.imp, ctx.inputNames)}`);
2105
2427
  }
2106
2428
  }
2107
2429
  const limit = node.type === T.doWhile ? unwrapPlain(inputs.limit) : undefined;
@@ -2114,14 +2436,14 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2114
2436
  if (node.type === T.doWhile) {
2115
2437
  const condRaw = unwrapSource(inputs.condition);
2116
2438
  const cond = typeof condRaw === 'string' && condRaw.length
2117
- ? emitBareExpr(condRaw.startsWith('=js:') ? condRaw.slice(4) : condRaw, ctx.imp, ctx.inputNames)
2439
+ ? emitBareExpr(wireExprBody(condRaw), ctx.imp, ctx.inputNames)
2118
2440
  : `${ctx.imp.use('lit')}(true)`;
2119
2441
  segs.push(`.doWhile(${tsLiteral(node.id)}, ${cond}, ${armCb('b', bodySegs)}${opts})`);
2120
2442
  }
2121
2443
  else {
2122
2444
  const collRaw = unwrapSource(inputs.collection);
2123
2445
  const collection = collRaw !== undefined
2124
- ? emitBareExpr(collRaw.startsWith('=js:') ? collRaw.slice(4) : collRaw, ctx.imp, ctx.inputNames)
2446
+ ? emitBareExpr(wireExprBody(collRaw), ctx.imp, ctx.inputNames)
2125
2447
  : `${ctx.imp.use('lit')}([])`;
2126
2448
  segs.push(`.loop(${tsLiteral(node.id)}, ${collection}, ${armCb('b', bodySegs)}${opts})`);
2127
2449
  }
@@ -2133,12 +2455,22 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2133
2455
  const containerHandler = errorHandlerSeg(node.id, containerSucc, scope, ipdom, ctx);
2134
2456
  if (containerHandler)
2135
2457
  segs.push(containerHandler);
2136
- cursor = portTarget(ctx.graph, node.id, 'success');
2458
+ const next = continueOn(node, 'success', ctx, refInto);
2459
+ if (next.brk || next.cont) {
2460
+ segs.push(next.brk ? '.break()' : '.continue()');
2461
+ flagDropped(node, ctx, segs);
2462
+ return segs;
2463
+ }
2464
+ cursor = next.cursor;
2465
+ flagDropped(node, ctx, segs);
2137
2466
  continue;
2138
2467
  }
2139
2468
  if (node.type === T.subflow) {
2140
2469
  segs.push(`.step(${tsLiteral(node.id)}, ${emitSubflow(node, ctx)}${optsArg(node, ctx)})`);
2141
- cursor = scopeSucc(ctx.graph, node.id, scope)[0];
2470
+ cursor = follow(ctx.graph, node.id, scopeSucc(ctx.graph, node.id, scope)[0]);
2471
+ if (cursor === undefined)
2472
+ accountImplicitTail(node, ctx, refInto);
2473
+ flagDropped(node, ctx, segs);
2142
2474
  continue;
2143
2475
  }
2144
2476
  // A regular action step (mock / script / http / transform / connector / …).
@@ -2154,11 +2486,12 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2154
2486
  if (exitPorts && exitPorts.length > 1) {
2155
2487
  const r = ipdom.get(node.id) ?? stop;
2156
2488
  const armSrcs = exitPorts.map(({ value, port }) => {
2157
- const armSegs = region(portTarget(ctx.graph, node.id, port), r, scope, ipdom, ctx, refInto);
2489
+ const armSegs = edgeRegion(portEdge(ctx.graph, node.id, port), r, scope, ipdom, ctx, refInto);
2158
2490
  return `{ value: ${tsLiteral(value)}, body: ${armCb('b', armSegs)} }`;
2159
2491
  });
2160
2492
  const armList = `[\n${armSrcs.map((a) => reindent(a, ' ')).join(',\n')},\n]`;
2161
2493
  segs.push(`.stepSwitch(${tsLiteral(node.id)}, ${spec}, ${armList}${optsArg(node, ctx)})`);
2494
+ flagDropped(node, ctx, segs);
2162
2495
  cursor = r === EXIT ? undefined : r;
2163
2496
  continue;
2164
2497
  }
@@ -2173,7 +2506,7 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2173
2506
  // machinery an error handler uses.
2174
2507
  for (const be of ctx.graph.outEdges(node.id).filter((e) => e.sourcePort.startsWith('branch-'))) {
2175
2508
  const mainReach = forwardReach(succ, scope, ctx.graph);
2176
- const branchSegs = region(be.targetNodeId, EXIT, scope, ipdom, ctx, mainReach);
2509
+ const branchSegs = edgeRegion(be, EXIT, scope, ipdom, ctx, mainReach);
2177
2510
  if (branchSegs.length === 0)
2178
2511
  branchSegs.push('.return({})');
2179
2512
  segs.push(`.stepToList(${tsLiteral(be.sourcePort)}, ${armCb('p', branchSegs)})`);
@@ -2183,12 +2516,18 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2183
2516
  // already a successor (see Graph.primaryOutcomePort).
2184
2517
  // A converged fan-out has none: every outcome continues to the one
2185
2518
  // successor, which is what `.step()` recompiles to.
2186
- if (!ctx.graph.convergedFanOut(node.id)) {
2519
+ if (ctx.graph.convergedFanOut(node.id)) {
2520
+ // `.step()` with `outcomePorts: false` recompiles every one of these edges.
2521
+ for (const oe of ctx.graph.outEdges(node.id))
2522
+ if (oe.sourcePort.startsWith('outcome-'))
2523
+ ctx.graph.accounted.add(oe);
2524
+ }
2525
+ else {
2187
2526
  const primary = ctx.graph.primaryOutcomePort(node.id);
2188
2527
  for (const oe of ctx.graph.outEdges(node.id)
2189
2528
  .filter((e) => e.sourcePort.startsWith('outcome-') && e.sourcePort !== primary)) {
2190
2529
  const mainReach = forwardReach(succ, scope, ctx.graph);
2191
- const outSegs = region(oe.targetNodeId, EXIT, scope, ipdom, ctx, mainReach);
2530
+ const outSegs = edgeRegion(oe, EXIT, scope, ipdom, ctx, mainReach);
2192
2531
  if (outSegs.length === 0)
2193
2532
  outSegs.push('.return({})');
2194
2533
  segs.push(`.stepToList(${tsLiteral(oe.sourcePort)}, ${armCb('p', outSegs)})`);
@@ -2197,30 +2536,64 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2197
2536
  // A body edge into the enclosing container's break handle: the path exits
2198
2537
  // the loop here. Emit `.break()` and end the region — recompiling recreates
2199
2538
  // exactly this edge and adds no continue back-edge for the path.
2200
- if (ctx.breakInto !== undefined
2201
- && ctx.graph.outEdges(node.id).some((e) => e.targetNodeId === ctx.breakInto && e.targetPort === 'break')) {
2539
+ const breakEdges = ctx.breakInto === undefined
2540
+ ? []
2541
+ : ctx.graph.outEdges(node.id).filter((e) => e.targetNodeId === ctx.breakInto && e.targetPort === 'break');
2542
+ if (breakEdges.length > 0) {
2543
+ for (const be of breakEdges)
2544
+ ctx.graph.accounted.add(be);
2202
2545
  segs.push('.break()');
2546
+ flagDropped(node, ctx, segs);
2547
+ return segs;
2548
+ }
2549
+ // A SIDE-path edge into the container's continue handle (an `.onError`
2550
+ // handler, an HTTP branch or outcome list that goes on to the next item):
2551
+ // `.continue()`. On the body's main path the same edge is the one every body
2552
+ // tail gets, so it stays implicit there.
2553
+ const continueEdges = ctx.breakInto === undefined || refInto === undefined
2554
+ ? []
2555
+ : ctx.graph.outEdges(node.id).filter((e) => e.targetNodeId === ctx.breakInto
2556
+ && (e.targetPort === 'continue' || e.targetPort === 'loopBack'));
2557
+ if (continueEdges.length > 0) {
2558
+ for (const ce of continueEdges)
2559
+ ctx.graph.accounted.add(ce);
2560
+ segs.push('.continue()');
2561
+ flagDropped(node, ctx, segs);
2203
2562
  return segs;
2204
2563
  }
2205
2564
  // Continuation: 0 → terminal; 1 → follow; N same-port → parallel fork.
2206
- if (succ.length === 0)
2565
+ if (succ.length === 0) {
2566
+ accountImplicitTail(node, ctx, refInto);
2567
+ flagDropped(node, ctx, segs);
2207
2568
  return segs;
2569
+ }
2208
2570
  if (succ.length === 1) {
2209
- cursor = succ[0];
2571
+ cursor = follow(ctx.graph, node.id, succ[0]);
2572
+ flagDropped(node, ctx, segs);
2210
2573
  continue;
2211
2574
  }
2212
2575
  // Parallel: the fork's ipdom is the merge join; each successor is one arm.
2213
2576
  const join = ipdom.get(node.id);
2214
2577
  if (join && join !== EXIT && ctx.graph.byId.get(join)?.type === T.merge) {
2215
- const arms = succ.map((t, i) => armCb(`a${i}`, region(t, join, scope, ipdom, ctx, refInto)));
2578
+ const arms = succ.map((t, i) => armCb(`a${i}`, region(follow(ctx.graph, node.id, t), join, scope, ipdom, ctx, refInto)));
2216
2579
  segs.push(`.parallel(${tsLiteral(join)}, [\n${arms.map((a) => reindent(a, ' ')).join(',\n')},\n]${verArg(ctx.graph.byId.get(join))})`);
2217
- cursor = portTarget(ctx.graph, join, 'output');
2580
+ flagDropped(node, ctx, segs);
2581
+ const next = continueOn(ctx.graph.byId.get(join), 'output', ctx, refInto);
2582
+ if (next.brk || next.cont) {
2583
+ segs.push(next.brk ? '.break()' : '.continue()');
2584
+ return segs;
2585
+ }
2586
+ cursor = next.cursor;
2218
2587
  continue;
2219
2588
  }
2220
- // Unstructured fan-out we can't fold — best-effort marker.
2589
+ // Unstructured fan-out we can't fold — best-effort marker. Its successor
2590
+ // edges are what the marker reports, so they are not reported twice.
2221
2591
  if (ctx.o.bestEffort === false)
2222
2592
  throw new Error(`decompile: unstructured fan-out at "${node.id}".`);
2223
2593
  segs.push(`/* TODO: unstructured fan-out at ${commentText(node.id)} (${commentText(succ.length)} successors) */`);
2594
+ for (const t of succ)
2595
+ follow(ctx.graph, node.id, t);
2596
+ flagDropped(node, ctx, segs);
2224
2597
  return segs;
2225
2598
  }
2226
2599
  return segs;
@@ -2306,6 +2679,88 @@ function declaredExitsOfNode(node) {
2306
2679
  }
2307
2680
  return undefined;
2308
2681
  }
2682
+ /**
2683
+ * Split a switch case expression into `<discriminant> === <literal>`, the only
2684
+ * form `.switch(on, [{ value }])` models. Returns `undefined` for any other
2685
+ * condition, so the caller can refuse it rather than emit a different one:
2686
+ *
2687
+ * - the split is at the LAST top-level `===` (equality is left-associative,
2688
+ * so that `===` is the outermost one); `==`, `!==` and `!=` never match;
2689
+ * - the right side must be one literal: JSON, or a single-quoted string;
2690
+ * - the left side must hold no top-level operator that binds looser than
2691
+ * `===` (`&&`, `||`, `??`, `?:`, `&`, `|`, `^`, `,`, `=`, `=>`), since
2692
+ * `a && b === 1` means `a && (b === 1)`, not `(a && b) === 1`.
2693
+ *
2694
+ * Strings, template literals and bracketed groups are skipped when scanning.
2695
+ */
2696
+ function parseSwitchCase(expr) {
2697
+ let depth = 0;
2698
+ let lastEq = -1;
2699
+ const loose = [];
2700
+ for (let i = 0; i < expr.length; i++) {
2701
+ const ch = expr[i];
2702
+ if (ch === '"' || ch === "'" || ch === '`') {
2703
+ for (i++; i < expr.length && expr[i] !== ch; i++)
2704
+ if (expr[i] === '\\')
2705
+ i++;
2706
+ continue;
2707
+ }
2708
+ if (ch === '(' || ch === '[' || ch === '{') {
2709
+ depth++;
2710
+ continue;
2711
+ }
2712
+ if (ch === ')' || ch === ']' || ch === '}') {
2713
+ depth--;
2714
+ continue;
2715
+ }
2716
+ if (depth !== 0)
2717
+ continue;
2718
+ const two = expr.slice(i, i + 2);
2719
+ if (expr.startsWith('===', i)) {
2720
+ lastEq = i;
2721
+ i += 2;
2722
+ }
2723
+ else if (expr.startsWith('!==', i)) {
2724
+ i += 2;
2725
+ }
2726
+ else if (two === '?.' && !/[0-9]/.test(expr[i + 2] ?? '')) {
2727
+ i += 1; // optional chaining, not a conditional
2728
+ }
2729
+ else if (two === '==' || two === '!=' || two === '<=' || two === '>=') {
2730
+ i += 1;
2731
+ }
2732
+ else if (two === '&&' || two === '||' || two === '??' || two === '=>') {
2733
+ loose.push(i);
2734
+ i += 1;
2735
+ }
2736
+ else if ('?&|^,='.includes(ch)) {
2737
+ loose.push(i);
2738
+ }
2739
+ }
2740
+ if (lastEq < 0)
2741
+ return undefined;
2742
+ const lhs = expr.slice(0, lastEq).trim();
2743
+ const literal = expr.slice(lastEq + 3).trim();
2744
+ if (!lhs || loose.some((at) => at < lastEq))
2745
+ return undefined;
2746
+ try {
2747
+ return { lhs, literal, value: tsLiteral(JSON.parse(literal)) };
2748
+ }
2749
+ catch {
2750
+ const q = literal.match(/^'((?:[^'\\]|\\.)*)'$/);
2751
+ if (!q)
2752
+ return undefined;
2753
+ // The string's value, escapes resolved (`'it\'s'` is `it's`): rewrite it as
2754
+ // a JSON string, which shares JS's escapes except `\'`.
2755
+ const json = `"${q[1].replace(/\\(.)|"/g, (m, c) => (c === undefined ? '\\"' : c === "'" ? "'" : m))}"`;
2756
+ try {
2757
+ return { lhs, literal, value: tsLiteral(JSON.parse(json)) };
2758
+ }
2759
+ catch {
2760
+ return undefined;
2761
+ }
2762
+ }
2763
+ }
2309
2764
  /**
2310
2765
  * Emit a `.switch(...)` from a `core.logic.switch` node. Each case's serialized
2311
2766
  * expression is `<discriminant> === <JSON-literal>`; the discriminant (shared by
@@ -2315,62 +2770,74 @@ function declaredExitsOfNode(node) {
2315
2770
  function emitSwitch(node, ipdom, scope, ctx, stop, refInto) {
2316
2771
  const r = ipdom.get(node.id) ?? stop;
2317
2772
  const cases = (node.inputs ?? {}).cases ?? [];
2773
+ let caseIndex = 0;
2318
2774
  let disc;
2319
2775
  let nonUniform = false; // a case whose expr isn't `<disc> === <literal>`, or a differing discriminant
2320
2776
  const caseSrcs = [];
2321
2777
  for (const c of cases) {
2322
- // SDK-emitted switch cases are `<discriminant> === <JSON-literal>` (all cases
2323
- // share the discriminant). Accept `==` too (hand-authored flows use it).
2324
- const m = String(c.expression ?? '').match(/^(.*?)\s*===?\s*(.*)$/);
2325
- const lhs = m ? m[1].trim() : '';
2326
- if (m && lhs) {
2778
+ const parsed = parseSwitchCase(String(c.expression ?? ''));
2779
+ let value = '0';
2780
+ let valueLiteral = '';
2781
+ if (parsed) {
2327
2782
  if (disc === undefined)
2328
- disc = lhs;
2329
- else if (disc !== lhs)
2783
+ disc = parsed.lhs;
2784
+ else if (disc !== parsed.lhs)
2330
2785
  nonUniform = true;
2786
+ valueLiteral = parsed.literal;
2787
+ value = parsed.value;
2331
2788
  }
2332
2789
  else {
2333
2790
  nonUniform = true;
2334
- }
2335
- let value = '0';
2336
- let valueLiteral = '';
2337
- if (m) {
2338
- valueLiteral = m[2].trim();
2339
- try {
2340
- value = tsLiteral(JSON.parse(valueLiteral));
2341
- }
2342
- catch {
2343
- // A non-JSON literal (e.g. single-quoted) — strip matching quotes if any.
2344
- const q = valueLiteral.match(/^'(.*)'$/);
2345
- value = q ? tsLiteral(q[1]) : tsLiteral(valueLiteral);
2791
+ // Keep the best-effort output the TODO comment points at.
2792
+ const m = String(c.expression ?? '').match(/^(.*?)\s*===?\s*(.*)$/);
2793
+ if (m && m[1].trim() && disc === undefined)
2794
+ disc = m[1].trim();
2795
+ if (m) {
2796
+ valueLiteral = m[2].trim();
2797
+ try {
2798
+ value = tsLiteral(JSON.parse(valueLiteral));
2799
+ }
2800
+ catch {
2801
+ const q = valueLiteral.match(/^'(.*)'$/);
2802
+ value = q ? tsLiteral(q[1]) : tsLiteral(valueLiteral);
2803
+ }
2346
2804
  }
2347
2805
  }
2348
- else {
2349
- nonUniform = true;
2350
- }
2351
- const armSegs = region(portTarget(ctx.graph, node.id, `case-${c.id}`), r, scope, ipdom, ctx, refInto);
2806
+ const armSegs = edgeRegion(portEdge(ctx.graph, node.id, `case-${c.id}`), r, scope, ipdom, ctx, refInto);
2352
2807
  const defaultLabel = `Case ${valueLiteral}`;
2353
2808
  const labelPart = c.label && c.label !== defaultLabel ? `, label: ${tsLiteral(c.label)}` : '';
2354
- caseSrcs.push(`{ value: ${value}${labelPart}, body: ${armCb('b', armSegs)} }`);
2809
+ // The file's case id comes back when it is not the positional one serialize
2810
+ // derives (a designer-drawn switch uses `default-1`, …), so the recompiled
2811
+ // edges leave from the ORIGINAL node's handles, which merge keeps (#1130).
2812
+ caseIndex += 1;
2813
+ const idPart = typeof c.id === 'string' && c.id !== '' && c.id !== `case_${caseIndex}` ? `, id: ${tsLiteral(c.id)}` : '';
2814
+ caseSrcs.push(`{ value: ${value}${labelPart}${idPart}, body: ${armCb('b', armSegs)} }`);
2355
2815
  }
2356
2816
  const on = emitBareExpr(disc ?? '', ctx.imp, ctx.inputNames);
2357
- const hasDefault = (node.inputs ?? {}).hasDefault === true || portTarget(ctx.graph, node.id, 'default') !== undefined;
2817
+ const hasDefault = (node.inputs ?? {}).hasDefault === true || portEdge(ctx.graph, node.id, 'default') !== undefined;
2358
2818
  const defArm = hasDefault
2359
- ? `, ${armCb('d', region(portTarget(ctx.graph, node.id, 'default'), r, scope, ipdom, ctx, refInto))}`
2819
+ ? `, ${armCb('d', edgeRegion(portEdge(ctx.graph, node.id, 'default'), r, scope, ipdom, ctx, refInto))}`
2360
2820
  : '';
2361
2821
  const caseList = `[\n${caseSrcs.map((c) => reindent(c, ' ')).join(',\n')},\n]`;
2362
2822
  // The builder's `.switch(on, [{value}])` models `on === value` only. A flow whose
2363
2823
  // cases carry arbitrary per-case boolean expressions (or a non-uniform
2364
2824
  // discriminant) cannot round-trip through it — flag it rather than emit silently
2365
2825
  // wrong values.
2826
+ if (nonUniform) {
2827
+ const listing = cases.map((c) => `${tsLiteral(c.label ?? c.id)}: ${tsLiteral(String(c.expression ?? ''))}`).join(', ');
2828
+ const message = `switch ${tsLiteral(node.id)} has per-case conditions that are not one shared \`<discriminant> === <value>\` `
2829
+ + `(${listing}). \`.switch()\` models only that form, so the decompiled \`on\`/\`value\` are placeholders: `
2830
+ + 'recompiling either fails or changes which case runs. Edit this switch in the .flow JSON directly.';
2831
+ if (runStrict)
2832
+ throw new Error(`decompile: ${message}`);
2833
+ warnOnce(message);
2834
+ }
2366
2835
  const warn = nonUniform
2367
2836
  ? `\n/* TODO: this switch's case expressions are not a uniform \`<discriminant> === <value>\`; the recovered on/value are best-effort and need review. */`
2368
2837
  : '';
2369
2838
  const ver = verArg(node);
2370
2839
  return `.switch(${tsLiteral(node.id)}, ${on}, ${caseList}${defArm}${ver && !defArm ? ', undefined' : ''}${ver})${warn}`;
2371
2840
  }
2372
- /** The manifest-less annotation node type — the only annotation `.flow` has. */
2373
- const STICKY_NOTE_TYPE = 'stickyNote';
2374
2841
  /**
2375
2842
  * Recover `.stickyNotes([...])` from the scope's `stickyNote` nodes.
2376
2843
  *
@@ -2438,12 +2905,21 @@ function stickyNotesSeg(nodes, flow) {
2438
2905
  */
2439
2906
  function layoutSeg(flow, nodes, edges) {
2440
2907
  const hoisted = (flow.layout?.nodes ?? {});
2908
+ // A layout key must name a node the recompiled flow will HAVE. A resource
2909
+ // folded into its agent comes back under the id compile mints for it; when
2910
+ // that is not the file's id (a canvas- or CLI-placed resource is named after
2911
+ // its index or tool, not `<agent>Context`), the file's id names nothing and
2912
+ // compile refuses the whole `.layout()`. So its entry, and any edge route that
2913
+ // ends at it, is left out and compile places the node itself.
2914
+ const renamed = (id) => runAbsorbed.has(id) && runAbsorbed.get(id) !== id;
2441
2915
  const nodeEntries = [];
2442
2916
  for (const n of nodes) {
2443
2917
  // A sticky note carries its own geometry in `.stickyNotes()`; a layout entry
2444
2918
  // for it would be a second, competing source for the same coordinates.
2445
2919
  if (n.type === STICKY_NOTE_TYPE)
2446
2920
  continue;
2921
+ if (renamed(n.id))
2922
+ continue;
2447
2923
  const ui = { ...(n.ui ?? {}), ...(hoisted[n.id] ?? {}) };
2448
2924
  const pos = ui.position;
2449
2925
  if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number')
@@ -2480,6 +2956,8 @@ function layoutSeg(flow, nodes, edges) {
2480
2956
  }
2481
2957
  if (!parts.length)
2482
2958
  continue;
2959
+ if (renamed(e.sourceNodeId) || renamed(e.targetNodeId))
2960
+ continue;
2483
2961
  const pair = `${e.sourceNodeId}->${e.targetNodeId}`;
2484
2962
  if ((pairCount.get(pair) ?? 0) !== 1)
2485
2963
  continue; // ambiguous — see the note above
@@ -2542,10 +3020,11 @@ function emitChildFlow(name, child, ctx) {
2542
3020
  // Body: the child's trigger is its own manual Start; walk from its successor.
2543
3021
  const trigger = (child.nodes ?? []).find((n) => isTrigger(n.type) && !(child.edges ?? []).some((e) => e.targetNodeId === n.id));
2544
3022
  const scope = scopeIds(child.nodes ?? [], undefined);
2545
- const entry = trigger ? scopeSucc(childCtx.graph, trigger.id, scope)[0] : undefined;
3023
+ const entry = trigger ? follow(childCtx.graph, trigger.id, scopeSucc(childCtx.graph, trigger.id, scope)[0]) : undefined;
2546
3024
  const ipdom = entry ? postDominators(entry, scope, childCtx.graph) : new Map();
2547
3025
  const bodySegs = entry ? region(entry, EXIT, scope, ipdom, childCtx) : [];
2548
3026
  parts.push(...bodySegs);
3027
+ parts.push(...sweepDroppedEdges(childCtx.graph, new Set()));
2549
3028
  // A subflow carries its own scoped layout, resolved against its own node ids.
2550
3029
  const childNotes = stickyNotesSeg(child.nodes ?? [], child);
2551
3030
  if (childNotes)
@@ -2568,11 +3047,13 @@ export function decompile(flow, options = {}) {
2568
3047
  runWarn = options.warn;
2569
3048
  runReadSidecar = options.readSidecar;
2570
3049
  runWarned = new Set();
3050
+ runDropped = [];
2571
3051
  runDefPairs = new Set((flow.definitions ?? [])
2572
3052
  .filter((d) => typeof d?.nodeType === 'string' && typeof d?.version === 'string')
2573
3053
  .map((d) => `${d.nodeType}@${d.version}`));
2574
3054
  runDefs = new Map();
2575
3055
  runRawManifests = [];
3056
+ runAbsorbed = new Map();
2576
3057
  for (const d of (flow.definitions ?? [])) {
2577
3058
  if (typeof d?.nodeType !== 'string')
2578
3059
  continue;
@@ -2663,7 +3144,7 @@ export function decompile(flow, options = {}) {
2663
3144
  const ctx = { graph, flow, imp, inputNames, o: options, emitted: new Set(), shared };
2664
3145
  const scope = scopeIds(flow.nodes ?? [], undefined);
2665
3146
  const startId = trigger?.id ?? 'start';
2666
- const first = scopeSucc(graph, startId, scope)[0];
3147
+ const first = follow(graph, startId, scopeSucc(graph, startId, scope)[0]);
2667
3148
  const ipdom = first !== undefined ? postDominators(first, scope, graph) : new Map();
2668
3149
  const bodySegs = first !== undefined ? region(first, EXIT, scope, ipdom, ctx) : [];
2669
3150
  // Additional roots decompile to .entryPoint(...): scoped inputs from the
@@ -2676,6 +3157,15 @@ export function decompile(flow, options = {}) {
2676
3157
  continue;
2677
3158
  extraEps.push(entryPointSeg(t, scope, ipdom, ctx));
2678
3159
  }
3160
+ // Edge accounting (#1125): the walk above has claimed every edge it emitted
3161
+ // or that serialize recreates on its own. The rest are not in the source.
3162
+ const unreached = sweepDroppedEdges(graph, detachedIds);
3163
+ for (const line of unreached)
3164
+ e.line(d, line);
3165
+ if (runStrict && runDropped.length > 0) {
3166
+ throw new Error(`decompile: ${runDropped.length} edge(s) have no spelling in the decompiled source, so recompiling would drop them: `
3167
+ + `${runDropped.map(edgeName).join(', ')}.`);
3168
+ }
2679
3169
  const header = e.toString();
2680
3170
  const body = bodySegs.map((s) => reindent(s, ' ')).join('\n');
2681
3171
  const eps = extraEps.map((s) => reindent(s, ' ')).join('\n');
@@ -2746,7 +3236,7 @@ function entryPointSeg(t, scope, ipdom, ctx) {
2746
3236
  optParts.push(`inputs: { ${scoped.map((g) => `${key(g.name ?? g.id)}: ${declExpr(g, true, ctx.imp, sharedOf(g))}`).join(', ')} }`);
2747
3237
  }
2748
3238
  optParts.push(...triggerOptParts(t));
2749
- const first = scopeSucc(ctx.graph, t.id, scope)[0];
3239
+ const first = follow(ctx.graph, t.id, scopeSucc(ctx.graph, t.id, scope)[0]);
2750
3240
  const prefixSegs = first !== undefined ? region(first, EXIT, scope, ipdom, ctx) : [];
2751
3241
  const optArgText = optParts.length ? `, { ${optParts.join(', ')} }` : (prefixSegs.length ? ', undefined' : '');
2752
3242
  const prefix = prefixSegs.length ? `, ${armCb('b', prefixSegs)}` : '';