@uipath/maestro-builder-sdk 6.44.2 → 6.44.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/check.js CHANGED
@@ -22,6 +22,7 @@ import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
22
22
  import { compareSchemaVersions, schemaVersionRefusal } from './schema-version.js';
23
23
  import { planFlowGlobals, planSharedInputs, sharedInputRoots } from './flow-variables.js';
24
24
  import { planOutputBacking } from './flow-output-backing.js';
25
+ import { VARS_READ_RE } from './core/vars-read.js';
25
26
  import { terminateStepClashMessage, terminateTwiceMessage } from './flow-terminate-names.js';
26
27
  import { endContentKey } from './flow-end-content.js';
27
28
  import { FLOW_NODE_TYPES } from './flow-node-registry.js';
@@ -781,9 +782,12 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
781
782
  }
782
783
  }
783
784
  else if (s.kind === 'return') {
785
+ // A named End (`.return({…}, { name })`) is a node merge can place a
786
+ // finding on, so its expressions report under that name (#1322); an
787
+ // unnamed one has no id until compile, so it stays `return`.
784
788
  for (const [name, e] of Object.entries(s.values)) {
785
- addExpr(e.js, 'return');
786
- checkOutputCategory(name, e, outputType, typeOf, diags, triggerId);
789
+ addExpr(e.js, s.options?.name ?? 'return');
790
+ checkOutputCategory(name, e, outputType, typeOf, diags, triggerId, s.options?.name ?? 'return');
787
791
  }
788
792
  }
789
793
  // AFTER the dispatch chain, not inside it: `s` was normalised to an action
@@ -1056,7 +1060,7 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
1056
1060
  // subflow read, where the value lives one level deeper
1057
1061
  // (`$vars.<call>.output.<childOutput>`). Every other rule reads only the
1058
1062
  // first two segments, exactly as before.
1059
- for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)(?:\??\.([A-Za-z0-9_]+))?((?:\??\.[A-Za-z0-9_]+)*)/g)) {
1063
+ for (const m of js.matchAll(VARS_READ_RE)) {
1060
1064
  const [, name, field] = m;
1061
1065
  // `?.` reads the same path as `.`, and the rules below parse the tail as `.a.b`.
1062
1066
  const rest = m[3].replace(/\?\./g, '.');
@@ -1157,7 +1161,10 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
1157
1161
  // (`<step>.error`), which is what `err()` writes; on managed http it is the
1158
1162
  // output itself, and `serialize` rewrites the read to match. Either way this
1159
1163
  // spelling resolves to nothing, silently: the handler runs and reports empty.
1160
- if (field === 'output' && rest.startsWith('.error')) {
1164
+ // The FIELD `error`, not a prefix: `error_type`, `error_message`,
1165
+ // `errors` are success fields a function returns on its happy path, and
1166
+ // two designer-saved flows report outcomes that way (#1321).
1167
+ if (field === 'output' && /^\.error(?:\.|$)/.test(rest)) {
1161
1168
  const wanted = /^\.error\.([A-Za-z0-9_]+)/.exec(rest);
1162
1169
  diags.push({
1163
1170
  level: 'error',
@@ -1734,7 +1741,7 @@ function typeCategory(t) {
1734
1741
  * its declared type is known. A `tmpl`/`js` expression produces text or something
1735
1742
  * unknowable, so it is left alone.
1736
1743
  */
1737
- function checkOutputCategory(name, e, outputType, typeOf, diags, triggerId) {
1744
+ function checkOutputCategory(name, e, outputType, typeOf, diags, triggerId, step = 'return') {
1738
1745
  const declaredOut = outputType.get(name);
1739
1746
  if (!declaredOut || e.literal)
1740
1747
  return;
@@ -1756,7 +1763,7 @@ function checkOutputCategory(name, e, outputType, typeOf, diags, triggerId) {
1756
1763
  message: `Output "${name}" is declared ${declaredOut} but returns "${refName}", which is `
1757
1764
  + `${typeOf.get(refName)}. That is a type error, so the output would come back `
1758
1765
  + `empty at run time.`,
1759
- step: 'return',
1766
+ step,
1760
1767
  suggestion: fix,
1761
1768
  });
1762
1769
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A dotted `$vars` read: `$vars.<name>`, an optional `.<field>`, then the dotted
3
+ * `rest`, each dot optionally `?.`. It stops at the first bracket. Check and
4
+ * decompile both scan with it, so decompile declares the body fields check reads.
5
+ */
6
+ export declare const VARS_READ_RE: RegExp;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A dotted `$vars` read: `$vars.<name>`, an optional `.<field>`, then the dotted
3
+ * `rest`, each dot optionally `?.`. It stops at the first bracket. Check and
4
+ * decompile both scan with it, so decompile declares the body fields check reads.
5
+ */
6
+ export const VARS_READ_RE = /\$vars\.([A-Za-z0-9_]+)(?:\??\.([A-Za-z0-9_]+))?((?:\??\.[A-Za-z0-9_]+)*)/g;
package/dist/decompile.js CHANGED
@@ -42,6 +42,7 @@ import { parseIxpProjectType } from './core/actions.js';
42
42
  import { readFlowGlobals } from './flow-variables.js';
43
43
  import { outputBackingGlobal, outputBackingName } from './flow-output-backing.js';
44
44
  import { mapJsCode } from './core/js-code-spans.js';
45
+ import { VARS_READ_RE } from './core/vars-read.js';
45
46
  import { missingBindingLabels } from './core/binding-messages.js';
46
47
  import { prepareCommand } from './core/cli-spelling.js';
47
48
  import { OUTCOME_HANDLE_NODE_TYPES } from './core/hitl-routing.js';
@@ -459,6 +460,30 @@ function emitValMap(m, imp, inputNames) {
459
460
  */
460
461
  const ARTIFACT_PORTS = new Set(['tool', 'context', 'escalation', 'memory']);
461
462
  // ─── Graph model ──────────────────────────────────────────────────────────────
463
+ /** An index right after a read: `[0]`, `[i]`, `?.["k"]`. */
464
+ const INDEX_NEXT_RE = /^\s*(?:\?\.)?\[/;
465
+ /** A read's tail when an index follows it, whatever the index: `.output.body.items[]`. */
466
+ const INDEX_MARK = '[]';
467
+ /** Every `$vars` read in `values`' strings, by id, with `?.` read as `.`. */
468
+ function scopeReads(values) {
469
+ const reads = new Map();
470
+ const visit = (v) => {
471
+ if (typeof v === 'string') {
472
+ for (const m of v.matchAll(VARS_READ_RE)) {
473
+ const [read, id, field, rest] = m;
474
+ const path = field === undefined ? '' : `.${field}${rest.replace(/\?\./g, '.')}`;
475
+ const indexed = INDEX_NEXT_RE.test(v.slice(m.index + read.length));
476
+ (reads.get(id) ?? reads.set(id, []).get(id)).push(indexed ? path + INDEX_MARK : path);
477
+ }
478
+ return;
479
+ }
480
+ if (v && typeof v === 'object')
481
+ for (const x of Object.values(v))
482
+ visit(x);
483
+ };
484
+ visit(values);
485
+ return reads;
486
+ }
462
487
  class Graph {
463
488
  byId = new Map();
464
489
  out = new Map();
@@ -474,8 +499,11 @@ class Graph {
474
499
  */
475
500
  accounted = new Set();
476
501
  edges;
477
- constructor(nodes, edges) {
502
+ variables;
503
+ reads;
504
+ constructor(nodes, edges, variables) {
478
505
  this.edges = edges;
506
+ this.variables = variables;
479
507
  for (const n of nodes)
480
508
  this.byId.set(n.id, n);
481
509
  for (const e of edges) {
@@ -483,6 +511,11 @@ class Graph {
483
511
  (this.inc.get(e.targetNodeId) ?? this.inc.set(e.targetNodeId, []).get(e.targetNodeId)).push(e);
484
512
  }
485
513
  }
514
+ /** The tails this scope's inputs, outputs and updates read off `$vars.<id>`, e.g. `.output.body.current`. */
515
+ readsOf(id) {
516
+ this.reads ??= scopeReads([...[...this.byId.values()].flatMap((n) => [n.inputs, n.outputs]), this.variables?.variableUpdates]);
517
+ return this.reads.get(id) ?? [];
518
+ }
486
519
  outEdges(id) {
487
520
  return this.out.get(id) ?? [];
488
521
  }
@@ -666,6 +699,7 @@ function liftRegistered(node, impl = registeredImpl(node), graph) {
666
699
  absorb: (id, compiledId) => { runAbsorbed.set(id, compiledId); },
667
700
  outPorts: () => (graph?.outEdges(node.id) ?? []).map((e) => e.sourcePort),
668
701
  convergedFanOut: () => graph?.convergedFanOut(node.id) === true,
702
+ reads: () => graph?.readsOf(node.id) ?? [],
669
703
  };
670
704
  const spec = impl.deserialize(node, ctx);
671
705
  return spec === undefined ? undefined : { impl, spec };
@@ -2556,7 +2590,7 @@ function readChildStartAsStart(child, startId) {
2556
2590
  function emitChildFlowIn(name, child, trigger, ctx) {
2557
2591
  const globals = readFlowGlobals(child.variables, runStrict);
2558
2592
  const childCtx = {
2559
- graph: new Graph(child.nodes ?? [], child.edges ?? []),
2593
+ graph: new Graph(child.nodes ?? [], child.edges ?? [], child.variables),
2560
2594
  flow: child,
2561
2595
  imp: ctx.imp,
2562
2596
  inputNames: new Set(globals.filter((g) => g.direction === 'in').map((g) => g.name ?? g.id)),
@@ -2721,7 +2755,7 @@ export function decompile(flow, options = {}) {
2721
2755
  }
2722
2756
  runCarried = carriedPairs(flow);
2723
2757
  const imp = new Imports();
2724
- const graph = new Graph(flow.nodes ?? [], flow.edges ?? []);
2758
+ const graph = new Graph(flow.nodes ?? [], flow.edges ?? [], flow.variables);
2725
2759
  const globals = readFlowGlobals(flow.variables, runStrict);
2726
2760
  const inputNames = new Set(globals.filter((g) => g.direction === 'in').map((g) => g.name ?? g.id));
2727
2761
  // Trigger-bound globals name the node they came from; fall back to the graph's
@@ -281,6 +281,8 @@ export interface FlowDeserializeContext {
281
281
  * the type's `exits()`, so it is `false` for any other node.
282
282
  */
283
283
  convergedFanOut(): boolean;
284
+ /** The tails the node's scope reads off `$vars.<nodeId>`, e.g. `.output.body.current`, `.output.body.items[]` for an indexed read. */
285
+ reads(): readonly string[];
284
286
  }
285
287
  /** What `print` may ask of the decompiler. Every method returns source text. */
286
288
  export interface FlowPrintContext {
package/dist/merge-cli.js CHANGED
@@ -54,7 +54,7 @@ import { ensureTypeScriptRuntime } from './node-runtime.js';
54
54
  import { flowCheckOpts, openCliLibrary } from './cli-library.js';
55
55
  import { Bindings } from './bindings.js';
56
56
  import { defaultBindingsFile } from './workdir.js';
57
- import { baselineFromOriginal, checkEdited, classifyDrafts, compileSourceDraftTolerant } from './merge-compile.js';
57
+ import { baselineFromOriginal, checkEdited, classifyDrafts, compileSourceDraftTolerant, findingCounts, splitUnplaced } from './merge-compile.js';
58
58
  const USAGE = 'usage: flow-merge <original.flow> <edited.flow | Edited.flow.ts> [-o merged.flow] '
59
59
  + '[--baseline <baseline.flow> | --baseline-from <original.flow>] [--library <dir>] [--bindings <file>] [--strict]';
60
60
  /**
@@ -215,19 +215,41 @@ export async function run(argv) {
215
215
  // acceptable only where merge restores the ORIGINAL node; one the author
216
216
  // changed or added gets the strict compile's refusal (#1134).
217
217
  // check()'s errors follow the same rule; its warnings are printed as compile prints them.
218
- const { kept, refused } = classifyDrafts(original, edited, baseline, drafts);
218
+ const { kept, refused, unplaced } = classifyDrafts(original, edited, baseline, drafts);
219
219
  const checked = classifyDrafts(original, edited, baseline, diagnostics.filter((d) => d.level === 'error'));
220
220
  for (const d of diagnostics)
221
221
  if (d.level === 'warning')
222
222
  console.error(` ⚠ [${d.code}] ${d.message}`);
223
223
  for (const d of [...kept, ...checked.kept]) {
224
- console.error(` ⚠ [DRAFT_NODE_KEPT] "${d.nodeId}" is unfinished in the original (${d.code}); it is untouched, so the original node is kept as is.`);
224
+ console.error(d.subflow !== undefined
225
+ ? ` ⚠ [DRAFT_NODE_KEPT] "${d.nodeId}" is unfinished in the original (${d.code}); it is inside subflow "${d.subflow}", which the edit did not touch, so the original subflow is kept as is.`
226
+ : ` ⚠ [DRAFT_NODE_KEPT] "${d.nodeId}" is unfinished in the original (${d.code}); it is untouched, so the original node is kept as is.`);
225
227
  }
226
- const errors = [...refused, ...checked.refused];
228
+ // A finding merge cannot place on a node — an unnamed End's `.return`, a
229
+ // flow-level finding — is printed when the ORIGINAL's own round trip reports
230
+ // it too (the designer's file had it; check has already said it), and
231
+ // refused when it does not: then the edit introduced it (#1322). With a
232
+ // precompiled --baseline file there is nothing to compare against, so every
233
+ // one is refused, as before.
234
+ const baselineFindings = baselineCompile === undefined ? undefined : findingCounts([
235
+ ...baselineCompile.drafts,
236
+ ...checkEdited(baselineCompile.built, baselineCompile.flow, flowCheckOpts(library, bindings)).filter((d) => d.level === 'error'),
237
+ ]);
238
+ const unplacedDrafts = splitUnplaced(unplaced, baselineFindings);
239
+ const unplacedChecks = splitUnplaced(checked.unplaced, baselineFindings);
240
+ for (const d of [...unplacedDrafts.known, ...unplacedChecks.known]) {
241
+ console.error(` ⚠ [CHECK_FINDING_UNPLACED] ${d.code} names no node, and the original's own round trip reports it too, so it does not stop the write: ${d.message}`);
242
+ }
243
+ const errors = [
244
+ ...refused, ...checked.refused,
245
+ ...[...unplacedDrafts.introduced, ...unplacedChecks.introduced].map((d) => ({ ...d, reason: 'unplaced' })),
246
+ ];
227
247
  if (errors.length) {
228
248
  for (const d of errors) {
229
- const where = d.nodeId === '' ? 'the edited flow'
230
- : d.reason === 'subflow' ? `"${d.nodeId}" is inside a subflow, which merge takes whole; finish it`
249
+ const where = d.reason === 'unplaced'
250
+ ? 'the edited flow (this finding names no node, and the original\'s own round trip does not report it, so the edit introduced it)'
251
+ : d.reason === 'subflow'
252
+ ? `"${d.nodeId}" is inside subflow "${d.subflow}", which the edit changed, so merge takes that subflow whole; finish it`
231
253
  : `"${d.nodeId}" is changed or new in the edited flow`;
232
254
  console.error(` ✗ [${d.code}] ${where}: ${d.message}`);
233
255
  }
@@ -27,36 +27,97 @@ export declare function compileSourceDraftTolerant(file: string, opts?: Omit<Ser
27
27
  * it, as `flow decompile` does), then compile the source with draft tolerance.
28
28
  * The source goes to a temporary directory that is removed afterwards.
29
29
  */
30
- export declare function baselineFromOriginal(originalPath: string, opts?: Omit<SerializeOpts, 'draft'>, warn?: (message: string) => void): Promise<DraftTolerantCompile>;
31
- /** Why merge takes a node from the edited flow, so a finding on it is an error. */
32
- export type TakenReason = 'edited' | 'subflow';
33
- /** A finding on a node merge takes from the edited flow, with the reason it takes it. */
30
+ export declare function baselineFromOriginal(originalPath: string, opts?: Omit<SerializeOpts, 'draft'>, warn?: (message: string) => void): Promise<DraftTolerantCompile & {
31
+ built: BuiltFlow;
32
+ }>;
33
+ /**
34
+ * Why a finding is an error: `edited`, its node is one the author changed or
35
+ * added; `subflow`, it sits in a subflow the edit changed, which merge takes
36
+ * whole; `unplaced`, it names no node and the original's own round trip does
37
+ * not report it, so the edit introduced it.
38
+ */
39
+ export type TakenReason = 'edited' | 'subflow' | 'unplaced';
40
+ /**
41
+ * A finding on a node merge takes from the edited flow, with the reason it
42
+ * takes it: `edited`, the author changed or added the node; `subflow`, the
43
+ * node sits in a subflow the edit changed, which merge takes whole (`subflow`
44
+ * names it).
45
+ */
34
46
  export type Refused<T> = T & {
35
47
  reason: TakenReason;
48
+ subflow?: string;
49
+ };
50
+ /** A finding on a node merge restores from the original; `subflow` names the untouched subflow it sits in. */
51
+ export type Kept<T> = T & {
52
+ subflow?: string;
36
53
  };
37
54
  /**
38
55
  * Sort each finding by what merge does with its node: `refused` when the node
39
56
  * comes from the edited flow (reason `edited`: the author changed or added it;
40
- * reason `subflow`: it sits in a subflow, which merge takes from `edited`
41
- * whole), `kept` when merge restores the original node.
57
+ * reason `subflow`: it sits in a subflow the edit changed, which merge takes
58
+ * whole), `kept` when merge restores the original node — a root node the edit
59
+ * did not touch, or any node of a subflow the edit did not touch (#1322) — and
60
+ * `unplaced` when the finding names no node merge knows (an unnamed End's
61
+ * `.return`, a flow-level finding). The caller sorts those again with
62
+ * {@link splitUnplaced}: one the original's own round trip reports too is
63
+ * printed, one the edit introduced is refused.
42
64
  *
43
65
  * For instance a Slack node with no connection that the author did not touch
44
66
  * is `kept`; the same node with a new message text is `refused` as `edited`.
45
67
  */
46
68
  export declare function classifyDrafts<T extends {
47
69
  nodeId: string;
70
+ subflow?: string;
48
71
  }>(original: any, edited: any, baseline: any, findings: readonly T[]): {
49
- kept: T[];
72
+ kept: Kept<T>[];
50
73
  refused: Refused<T>[];
74
+ unplaced: T[];
51
75
  };
52
- /** A `check()` diagnostic, placed on the root node its step emitted (`''` when it names no root step). */
76
+ /**
77
+ * A `check()` diagnostic, placed on the node its step emitted (`''` when it
78
+ * names no node); `subflow` is the calling step when the finding is inside a
79
+ * subflow, which then decides how it is classified.
80
+ */
53
81
  export type PlacedDiagnostic = Diagnostic & {
54
82
  nodeId: string;
83
+ subflow?: string;
55
84
  };
56
85
  /**
57
86
  * Run `check()` on the edited source, as `compile` does, and place each
58
87
  * diagnostic on the node its step emitted in `editedFlow`, so
59
- * {@link classifyDrafts} can sort it. A diagnostic with no step, or a step
60
- * with no root node, gets nodeId `''`, which is always refused.
88
+ * {@link classifyDrafts} can sort it: a root step on its node (by label, then
89
+ * id); a step inside a subflow (`check` reports it as `<caller> › <step>`, one
90
+ * segment per level) on the deepest step the path resolves to, with `subflow`
91
+ * set to the deepest subflow on that path, which is what decides; a named End
92
+ * on its End node (#1322). A diagnostic with no step, or one no node answers
93
+ * to, gets nodeId `''`.
61
94
  */
62
95
  export declare function checkEdited(built: BuiltFlow, editedFlow: any, opts: CheckOpts): PlacedDiagnostic[];
96
+ /** The identity of a finding for the introduced-or-not comparison: its code and message. */
97
+ export declare function findingKey(f: {
98
+ code: string;
99
+ message: string;
100
+ }): string;
101
+ /** How many times each {@link findingKey} occurs, for {@link splitUnplaced}'s baseline. */
102
+ export declare function findingCounts(findings: ReadonlyArray<{
103
+ code: string;
104
+ message: string;
105
+ }>): Map<string, number>;
106
+ /**
107
+ * Sort findings merge could not place on a node by whether the edit introduced
108
+ * them: `known` when the ORIGINAL's own round trip (`baseline`, the decompiled
109
+ * original compiled and checked, as {@link findingCounts}) reports the same
110
+ * code and message — once per occurrence, so a second copy of a finding the
111
+ * original has once is `introduced` — so the finding predates the edit and
112
+ * only prints; `introduced` otherwise, which is refused like any error on an
113
+ * edited node (#1322). With no baseline findings to compare against (a
114
+ * precompiled `--baseline` file), every one is `introduced`, as merge treated
115
+ * them before.
116
+ */
117
+ export declare function splitUnplaced<T extends {
118
+ code: string;
119
+ message: string;
120
+ }>(unplaced: readonly T[], baseline: ReadonlyMap<string, number> | undefined): {
121
+ known: T[];
122
+ introduced: T[];
123
+ };
@@ -13,7 +13,7 @@ import { decompile } from './decompile.js';
13
13
  import { loadBuiltFlow } from './load.js';
14
14
  import { serialize } from './serialize.js';
15
15
  import { check } from './check.js';
16
- import { nodesTakenFromEdited } from './merge.js';
16
+ import { nodesTakenFromEdited, subflowsTakenFromEdited } from './merge.js';
17
17
  import { PACKAGE_NAME } from './package-name.js';
18
18
  /** Compile `built` with draft tolerance. Every other refusal still throws. */
19
19
  export function compileDraftTolerant(built, opts = {}) {
@@ -63,32 +63,62 @@ export async function baselineFromOriginal(originalPath, opts = {}, warn) {
63
63
  /**
64
64
  * Sort each finding by what merge does with its node: `refused` when the node
65
65
  * comes from the edited flow (reason `edited`: the author changed or added it;
66
- * reason `subflow`: it sits in a subflow, which merge takes from `edited`
67
- * whole), `kept` when merge restores the original node.
66
+ * reason `subflow`: it sits in a subflow the edit changed, which merge takes
67
+ * whole), `kept` when merge restores the original node — a root node the edit
68
+ * did not touch, or any node of a subflow the edit did not touch (#1322) — and
69
+ * `unplaced` when the finding names no node merge knows (an unnamed End's
70
+ * `.return`, a flow-level finding). The caller sorts those again with
71
+ * {@link splitUnplaced}: one the original's own round trip reports too is
72
+ * printed, one the edit introduced is refused.
68
73
  *
69
74
  * For instance a Slack node with no connection that the author did not touch
70
75
  * is `kept`; the same node with a new message text is `refused` as `edited`.
71
76
  */
72
77
  export function classifyDrafts(original, edited, baseline, findings) {
73
78
  const fromEdited = new Set(nodesTakenFromEdited(original, edited, baseline));
79
+ const takenSubflows = new Set(subflowsTakenFromEdited(original, edited, baseline));
74
80
  const rootIds = new Set((edited?.nodes ?? []).map((n) => n.id));
81
+ /** A subflow's own node ids → the key (the calling step) of the subflow holding them. */
82
+ const subflowOfNode = new Map();
83
+ for (const [key, sf] of Object.entries(edited?.subflows ?? {})) {
84
+ for (const n of sf?.nodes ?? [])
85
+ subflowOfNode.set(n.id, key);
86
+ }
75
87
  const kept = [];
76
88
  const refused = [];
89
+ const unplaced = [];
77
90
  for (const f of findings) {
78
- if (!rootIds.has(f.nodeId))
79
- refused.push({ ...f, reason: 'subflow' });
80
- else if (fromEdited.has(f.nodeId))
81
- refused.push({ ...f, reason: 'edited' });
82
- else
83
- kept.push(f);
91
+ // A finding inside a subflow — placed on the calling step by `checkEdited`
92
+ // (`subflow` set), or a draft on one of the subflow's own nodes — is judged
93
+ // by the SUBFLOW: taken whole when the edit changed it, else the original's.
94
+ const subflow = f.subflow ?? subflowOfNode.get(f.nodeId);
95
+ if (subflow !== undefined) {
96
+ if (takenSubflows.has(subflow))
97
+ refused.push({ ...f, reason: 'subflow', subflow });
98
+ else
99
+ kept.push({ ...f, subflow });
100
+ }
101
+ else if (rootIds.has(f.nodeId)) {
102
+ if (fromEdited.has(f.nodeId))
103
+ refused.push({ ...f, reason: 'edited' });
104
+ else
105
+ kept.push(f);
106
+ }
107
+ else {
108
+ unplaced.push(f);
109
+ }
84
110
  }
85
- return { kept, refused };
111
+ return { kept, refused, unplaced };
86
112
  }
87
113
  /**
88
114
  * Run `check()` on the edited source, as `compile` does, and place each
89
115
  * diagnostic on the node its step emitted in `editedFlow`, so
90
- * {@link classifyDrafts} can sort it. A diagnostic with no step, or a step
91
- * with no root node, gets nodeId `''`, which is always refused.
116
+ * {@link classifyDrafts} can sort it: a root step on its node (by label, then
117
+ * id); a step inside a subflow (`check` reports it as `<caller> › <step>`, one
118
+ * segment per level) on the deepest step the path resolves to, with `subflow`
119
+ * set to the deepest subflow on that path, which is what decides; a named End
120
+ * on its End node (#1322). A diagnostic with no step, or one no node answers
121
+ * to, gets nodeId `''`.
92
122
  */
93
123
  export function checkEdited(built, editedFlow, opts) {
94
124
  const byStep = new Map();
@@ -98,8 +128,75 @@ export function checkEdited(built, editedFlow, opts) {
98
128
  byStep.set(label, n.id);
99
129
  }
100
130
  const ids = new Set((editedFlow?.nodes ?? []).map((n) => n.id));
101
- return check(built, opts).map((d) => ({
102
- ...d,
103
- nodeId: d.step === undefined ? '' : byStep.get(d.step) ?? (ids.has(d.step) ? d.step : ''),
104
- }));
131
+ /** `name` as a node id in `nodes`: by label, then by id; `''` when neither answers. */
132
+ const resolveIn = (nodes, name) => {
133
+ const byLabel = nodes.find((n) => n?.display?.label === name);
134
+ if (byLabel)
135
+ return byLabel.id;
136
+ return nodes.some((n) => n?.id === name) ? name : '';
137
+ };
138
+ // `subflows` is flat, keyed by each caller's id at every depth, and check
139
+ // prefixes a finding at every level (`outer › inner › x`): the subflow that
140
+ // decides is the DEEPEST one on the path, since merge keeps or takes each
141
+ // subflow on its own (an edit inside `inner` leaves `outer` kept).
142
+ const place = (step) => {
143
+ if (step === undefined)
144
+ return { nodeId: '' };
145
+ const segments = step.split(SUBFLOW_STEP_SEPARATOR);
146
+ let scope = editedFlow?.nodes ?? [];
147
+ let subflow;
148
+ let nodeId = '';
149
+ for (const [i, segment] of segments.entries()) {
150
+ const id = i === 0 ? (byStep.get(segment) ?? (ids.has(segment) ? segment : '')) : resolveIn(scope, segment);
151
+ if (id === '')
152
+ break;
153
+ nodeId = id;
154
+ const child = i < segments.length - 1 ? editedFlow?.subflows?.[id] : undefined;
155
+ if (child === undefined)
156
+ break;
157
+ subflow = id;
158
+ scope = child.nodes ?? [];
159
+ }
160
+ return subflow !== undefined ? { nodeId: nodeId === '' ? subflow : nodeId, subflow } : { nodeId };
161
+ };
162
+ return check(built, opts).map((d) => ({ ...d, ...place(d.step) }));
163
+ }
164
+ /** The identity of a finding for the introduced-or-not comparison: its code and message. */
165
+ export function findingKey(f) {
166
+ return `${f.code}\n${f.message}`;
167
+ }
168
+ /** How many times each {@link findingKey} occurs, for {@link splitUnplaced}'s baseline. */
169
+ export function findingCounts(findings) {
170
+ const counts = new Map();
171
+ for (const f of findings)
172
+ counts.set(findingKey(f), (counts.get(findingKey(f)) ?? 0) + 1);
173
+ return counts;
174
+ }
175
+ /**
176
+ * Sort findings merge could not place on a node by whether the edit introduced
177
+ * them: `known` when the ORIGINAL's own round trip (`baseline`, the decompiled
178
+ * original compiled and checked, as {@link findingCounts}) reports the same
179
+ * code and message — once per occurrence, so a second copy of a finding the
180
+ * original has once is `introduced` — so the finding predates the edit and
181
+ * only prints; `introduced` otherwise, which is refused like any error on an
182
+ * edited node (#1322). With no baseline findings to compare against (a
183
+ * precompiled `--baseline` file), every one is `introduced`, as merge treated
184
+ * them before.
185
+ */
186
+ export function splitUnplaced(unplaced, baseline) {
187
+ const known = [];
188
+ const introduced = [];
189
+ const left = new Map(baseline ?? []);
190
+ for (const f of unplaced) {
191
+ const n = left.get(findingKey(f)) ?? 0;
192
+ if (n > 0) {
193
+ left.set(findingKey(f), n - 1);
194
+ known.push(f);
195
+ }
196
+ else
197
+ introduced.push(f);
198
+ }
199
+ return { known, introduced };
105
200
  }
201
+ /** How `check` joins a subflow's calling step and the step inside it in a diagnostic's `step`. */
202
+ const SUBFLOW_STEP_SEPARATOR = ' › ';
package/dist/merge.d.ts CHANGED
@@ -181,6 +181,16 @@ export interface MergeOptions {
181
181
  * `original`, the answer is `[]` — a no-edit merge keeps them all.
182
182
  */
183
183
  export declare function nodesTakenFromEdited(original: AnyFlow, edited: AnyFlow, baseline?: AnyFlow): string[];
184
+ /**
185
+ * The keys of the subflows {@link mergeFlow} takes from `edited` rather than
186
+ * restoring from `original`: every subflow that is new, that the original does
187
+ * not have, or that differs from its `baseline` counterpart in any way the
188
+ * author can edit — a node's inputs, output sources or label, an edge, the
189
+ * scope's `variables` or `layout` (`subflowUnchanged`, #1322). With no
190
+ * baseline, that is every subflow. A kept subflow is the original's whole: its
191
+ * nodes, edges, variables and layout.
192
+ */
193
+ export declare function subflowsTakenFromEdited(original: AnyFlow, edited: AnyFlow, baseline?: AnyFlow): string[];
184
194
  export declare function mergeFlow(original: AnyFlow, edited: AnyFlow, options?: MergeOptions): AnyFlow;
185
195
  /**
186
196
  * `mergeFlow`, plus the agent sidecar files the merged flow needs written beside
package/dist/merge.js CHANGED
@@ -138,6 +138,68 @@ export function nodesTakenFromEdited(original, edited, baseline) {
138
138
  const baseNodes = byId(baseline?.nodes);
139
139
  return (edited.nodes ?? []).filter((e) => !isKept(e, origNodes, baseNodes, baseline)).map((e) => e.id);
140
140
  }
141
+ /**
142
+ * The keys of the subflows {@link mergeFlow} takes from `edited` rather than
143
+ * restoring from `original`: every subflow that is new, that the original does
144
+ * not have, or that differs from its `baseline` counterpart in any way the
145
+ * author can edit — a node's inputs, output sources or label, an edge, the
146
+ * scope's `variables` or `layout` (`subflowUnchanged`, #1322). With no
147
+ * baseline, that is every subflow. A kept subflow is the original's whole: its
148
+ * nodes, edges, variables and layout.
149
+ */
150
+ export function subflowsTakenFromEdited(original, edited, baseline) {
151
+ return Object.keys(edited.subflows ?? {}).filter((key) => {
152
+ const orig = original.subflows?.[key];
153
+ const base = baseline?.subflows?.[key];
154
+ return !baseline || orig === undefined || base === undefined
155
+ || !subflowUnchanged(edited.subflows[key], base);
156
+ });
157
+ }
158
+ /** A node's canvas label, when it has one. */
159
+ const labelOf = (n) => n?.display?.label;
160
+ /**
161
+ * Whether an edited subflow equals its baseline: the same node ids, each node
162
+ * unchanged (`nodeUnchanged`) and under the same label, the same edges by
163
+ * their endpoints and handles (ids are compile's own), and the same
164
+ * `variables` and `layout` byte for byte. The scope's variables carry what the
165
+ * nodes do not — a step's `{ updates }` row, a child `.input()` type, a `.var()`
166
+ * default — and a kept subflow is the original's whole, so any of them changing
167
+ * has to make the subflow a taken one.
168
+ */
169
+ function subflowUnchanged(e, b) {
170
+ const eNodes = byId(e.nodes);
171
+ const bNodes = byId(b.nodes);
172
+ if (eNodes.size !== bNodes.size)
173
+ return false;
174
+ for (const [id, n] of eNodes) {
175
+ const base = bNodes.get(id);
176
+ if (!nodeUnchanged(n, base) || labelOf(n) !== labelOf(base))
177
+ return false;
178
+ }
179
+ if (JSON.stringify(e.variables ?? null) !== JSON.stringify(b.variables ?? null))
180
+ return false;
181
+ // `layout.edges` is keyed by edge id, and ids come from one file-wide
182
+ // counter: a step added before the call shifts every id in the subflow
183
+ // without touching it. So routes are compared by the endpoints the id names
184
+ // (as the edges themselves are), and `layout.nodes` as it is.
185
+ if (JSON.stringify(layoutByEndpoints(e)) !== JSON.stringify(layoutByEndpoints(b)))
186
+ return false;
187
+ const eEdges = (e.edges ?? []).map(edgeKey).sort();
188
+ const bEdges = (b.edges ?? []).map(edgeKey).sort();
189
+ return eEdges.length === bEdges.length && eEdges.every((k, i) => k === bEdges[i]);
190
+ }
191
+ /** `<source>@<port>-><target>@<port>`: an edge's identity apart from its id. */
192
+ const edgeKey = (x) => `${x.sourceNodeId}@${x.sourcePort}->${x.targetNodeId}@${x.targetPort}`;
193
+ /** A scope's layout with its `edges` re-keyed by endpoints, so an id shift does not read as a change. */
194
+ function layoutByEndpoints(sf) {
195
+ const layout = sf.layout;
196
+ if (!layout || typeof layout !== 'object')
197
+ return layout ?? null;
198
+ const byId = new Map((sf.edges ?? []).map((x) => [x.id, edgeKey(x)]));
199
+ const { edges, ...rest } = layout;
200
+ const routes = Object.entries(edges ?? {}).map(([id, route]) => [byId.get(id) ?? id, route]).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
201
+ return { ...rest, edges: Object.fromEntries(routes) };
202
+ }
141
203
  /**
142
204
  * Whether merge restores `e`'s ORIGINAL node instead of taking `e`: there is a
143
205
  * baseline, the original has the node, and `e` equals its baseline counterpart.
@@ -343,15 +405,44 @@ function mergeFlowFindings(original, edited, options) {
343
405
  result.nodes = finalNodes;
344
406
  // Nodes present in the original but absent from `edited` were deleted by the
345
407
  // user; they are simply not carried over.
346
- // ── Subflows: the edited flow is authoritative (the user's graph). ──
408
+ // ── Subflows: the edited flow is authoritative (the user's graph) — except a
409
+ // subflow the edit never touched, which is the original's, whole: the same
410
+ // `kept` rule as a root node, one level down (#1322). Before, every subflow
411
+ // came from the edited compile, so a no-edit round trip rewrote each one
412
+ // in the SDK's shape, and a check error inside one refused the write. ──
347
413
  result.subflows = jsonClone(edited.subflows ?? {});
348
- // A subflow is taken from `edited` whole, so an embedded agent inside one
349
- // would flip to the edited shell even with no edit. Keep it embedded too.
414
+ const takenSubflows = new Set(subflowsTakenFromEdited(original, edited, options.baseline));
415
+ for (const key of Object.keys(result.subflows)) {
416
+ if (!takenSubflows.has(key))
417
+ result.subflows[key] = jsonClone(original.subflows[key]);
418
+ }
419
+ // A kept subflow carries the original's edge ids and a taken one the
420
+ // recompile's, from two different counters, so a taken subflow's edges are
421
+ // renumbered against every id a kept one holds (and against the taken ones
422
+ // before it), the way the root's are below.
423
+ const idScopes = Object.entries(result.subflows).filter(([k]) => !takenSubflows.has(k)).map(([, sf]) => sf);
424
+ for (const key of Object.keys(result.subflows)) {
425
+ if (!takenSubflows.has(key))
426
+ continue;
427
+ const sf = result.subflows[key];
428
+ const { edges: renumbered, renamed } = withFileUniqueEdgeIds(sf.edges ?? [], idScopes);
429
+ sf.edges = renumbered;
430
+ // A route in `layout.edges` is keyed by the edge's id, so it follows the rename.
431
+ const layout = sf.layout;
432
+ if (renamed.size > 0 && layout?.edges) {
433
+ layout.edges = Object.fromEntries(Object.entries(layout.edges).map(([id, route]) => [renamed.get(id) ?? id, route]));
434
+ }
435
+ idScopes.push(sf);
436
+ }
437
+ // A subflow taken from `edited` whole would flip an embedded agent inside it
438
+ // to the edited shell even with no edit to that node. Keep it embedded too.
350
439
  // This runs before the source fix-up below: it compares each node with the
351
- // baseline to tell which ones the author edited.
352
- const subflowEdited = keepEmbeddedAgentsInSubflows(original, result.subflows, options.baseline);
440
+ // baseline to tell which ones the author edited. A kept subflow is the
441
+ // original's already, so neither fix-up looks at it.
442
+ const takenEntries = Object.fromEntries(Object.entries(result.subflows).filter(([key]) => takenSubflows.has(key)));
443
+ const subflowEdited = keepEmbeddedAgentsInSubflows(original, takenEntries, options.baseline);
353
444
  if (options.baseline)
354
- keepOriginalSourcesInSubflows(original, result.subflows, options.baseline);
445
+ keepOriginalSourcesInSubflows(original, takenEntries, options.baseline);
355
446
  const subflowScopes = Object.values(result.subflows);
356
447
  // ── Variables: the same delta as nodes, per entry — what the author did not
357
448
  // change stays the original's, so compile's own spellings (an added
@@ -380,7 +471,7 @@ function mergeFlowFindings(original, edited, options) {
380
471
  // document (it drops one element or crashes). Only an edge with no id, or
381
472
  // one already taken elsewhere in the file, gets a fresh id, from ids unused
382
473
  // anywhere in the file. ──
383
- result.edges = withFileUniqueEdgeIds(edited.edges ?? [], subflowScopes);
474
+ result.edges = withFileUniqueEdgeIds(edited.edges ?? [], subflowScopes).edges;
384
475
  // ── Placement: a node the edit ADDED has no original position. Unless the
385
476
  // author set one, it has compile's placeholder, an emission-index slot
386
477
  // that can land on an original node. Put it next to its predecessor,
@@ -511,11 +602,16 @@ function withFileUniqueEdgeIds(edges, subflows) {
511
602
  n++;
512
603
  return `edge_${n++}`;
513
604
  };
514
- return edges.map((e) => {
605
+ /** Old id → new id, for every edge that had to move (a route keyed by the old id follows it). */
606
+ const renamed = new Map();
607
+ const out = edges.map((e) => {
515
608
  const id = typeof e.id === 'string' && !taken.has(e.id) ? e.id : fresh();
609
+ if (typeof e.id === 'string' && id !== e.id)
610
+ renamed.set(e.id, id);
516
611
  taken.add(id);
517
612
  return { ...jsonClone(e), id };
518
613
  });
614
+ return { edges: out, renamed };
519
615
  }
520
616
  /** The `nodeType@version` identity of a definition row or of a node's type pin. */
521
617
  function defKey(nodeType, version) {
@@ -245,6 +245,27 @@ function liftReturns(node) {
245
245
  .map(([k, v]) => [k, v.type]);
246
246
  return entries.length ? Object.fromEntries(entries) : undefined;
247
247
  }
248
+ /** A read of a body field: the field, then whatever the read goes on to. */
249
+ const BODY_READ_RE = /^\.output\.body\.([A-Za-z0-9_]+)(.*)$/;
250
+ /**
251
+ * The `returns` a node with no body schema needs for the body fields its flow
252
+ * already reads, so the source decompile prints compiles. A field read further
253
+ * in (`body.current.time`) is an `object`; a field read whole is a `string`, the
254
+ * type the check suggests. Undefined when nothing reads the body.
255
+ */
256
+ function returnsFromReads(reads) {
257
+ const fields = new Map();
258
+ for (const read of reads) {
259
+ const m = BODY_READ_RE.exec(read);
260
+ if (!m)
261
+ continue;
262
+ if (m[2] !== '')
263
+ fields.set(m[1], 'object');
264
+ else if (!fields.has(m[1]))
265
+ fields.set(m[1], 'string');
266
+ }
267
+ return fields.size ? Object.fromEntries(fields) : undefined;
268
+ }
248
269
  /**
249
270
  * Invert an HTTP node. The plain node keeps its request at the top level
250
271
  * (`url` / `method` / `headers` / `queryParams` / `body` / `contentType`); the
@@ -275,7 +296,7 @@ function liftHttp(node, ctx, managed) {
275
296
  throw new Error(`decompile: expected a number, got ${JSON.stringify(inputs.retryCount)}.`);
276
297
  }
277
298
  const branchRows = Array.isArray(inputs.branches) ? inputs.branches : [];
278
- const returns = liftReturns(node);
299
+ const returns = liftReturns(node) ?? returnsFromReads(ctx.reads());
279
300
  const spec = {
280
301
  url: value(req.url ?? ''),
281
302
  ...(text(req.method) !== undefined && req.method !== 'GET' ? { method: req.method } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "6.44.2",
3
+ "version": "6.44.4",
4
4
  "description": "Build UiPath Flow, Case, and BPMN artifacts by writing TypeScript.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.uipath.com/maestro",
@@ -93,5 +93,5 @@
93
93
  "@types/node": "^22.7.0",
94
94
  "esbuild": "^0.28.1"
95
95
  },
96
- "gitref": "7afed05cfe5a6856deb6fe7984a5cf2b31a2dd20"
96
+ "gitref": "cbc51e1db2b0ffabe85f841dee73f5a6301192d2"
97
97
  }