@uipath/maestro-builder-sdk 6.19.0 → 6.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +680 -104
  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/merge.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * preserved from the original; `subflows` and edges (with their ids) come from
29
29
  * `edited`; `definitions` and `bindings` are unioned
30
30
  * (original wins on conflict, since the original's are the tenant-resolved
31
- * authority). Node ids are stable by construction (decompile names every step by
31
+ * authority). A binding row's identity is what it binds, not its row id. Node ids are stable by construction (decompile names every step by
32
32
  * its node id), which is what lets every re-attach key on id.
33
33
  *
34
34
  * ## Definitions are keyed by `nodeType@version`, not by `nodeType`
@@ -63,8 +63,23 @@
63
63
  * A node whose pair is in NEITHER file cannot be given a definition here, and a
64
64
  * merge that stayed silent about it wrote a file it already knew would fail
65
65
  * `validate`. Those are reported through {@link MergeOptions.warn}.
66
+ *
67
+ * ## The baseline is also compared with the original
68
+ *
69
+ * `baseline` is the no-edit round trip, so wherever it differs from the original
70
+ * in wire identity (a node, an edge, a switch case id, a human-task outcome id,
71
+ * a binding row), the merged file inherits that change unless the author's edit
72
+ * undid it. Each difference the merged file still carries is reported through
73
+ * `warn`, and `strict` refuses the merge over it; the merged file is also
74
+ * checked for an edge that leaves a handle its kept source node does not
75
+ * declare. See merge-roundtrip.ts.
66
76
  */
77
+ import { bindingIdentity } from './core/binding-identity.js';
67
78
  import { jsonClone } from './core/json-file.js';
79
+ import { DEFINITIONLESS_NODE_TYPES } from './core/definitionless-node-types.js';
80
+ import { MergeRefusedError, mergedFileFindings, roundTripFindings } from './merge-roundtrip.js';
81
+ import { placeAddedNodes } from './node-placement.js';
82
+ export { MergeRefusedError } from './merge-roundtrip.js';
68
83
  function byId(nodes) {
69
84
  return new Map((nodes ?? []).map((n) => [n.id, n]));
70
85
  }
@@ -82,11 +97,31 @@ function nodeUnchanged(a, b) {
82
97
  return false;
83
98
  return JSON.stringify(a.inputs ?? {}) === JSON.stringify(b.inputs ?? {});
84
99
  }
85
- /** Graft the original node's layout (`ui`) onto an edited node, by id. */
86
- function withOriginalLayout(edited, orig) {
87
- if (!orig || orig.ui === undefined)
100
+ /**
101
+ * Give an edited node the original's position, by id.
102
+ *
103
+ * The original stores it in one of two places: `node.ui` (an SDK-compiled file)
104
+ * or `layout.nodes[id]` (every canvas-saved file — the designer's save moves
105
+ * `ui` into `layout.nodes`). From `node.ui` it is grafted onto the node. With a
106
+ * `layout.nodes` entry, the merged file keeps that entry (it is carried over
107
+ * with the original's `layout`) and the edited node's compile placeholder `ui`
108
+ * is dropped, so the node has one position, not two.
109
+ */
110
+ function withOriginalLayout(edited, orig, originalLayout) {
111
+ if (!orig)
88
112
  return edited;
89
- return { ...edited, ui: jsonClone(orig.ui) };
113
+ if (orig.ui !== undefined)
114
+ return { ...edited, ui: jsonClone(orig.ui) };
115
+ if (originalLayout?.[orig.id] !== undefined) {
116
+ const { ui: _placeholder, ...rest } = edited;
117
+ return rest;
118
+ }
119
+ return edited;
120
+ }
121
+ /** The original's `layout.nodes` map, when it has one. */
122
+ function layoutNodesOf(f) {
123
+ const nodes = f.layout?.nodes;
124
+ return nodes && typeof nodes === 'object' && !Array.isArray(nodes) ? nodes : undefined;
90
125
  }
91
126
  export function mergeFlow(original, edited, options = {}) {
92
127
  const result = jsonClone(original);
@@ -104,7 +139,11 @@ export function mergeFlow(original, edited, options = {}) {
104
139
  }
105
140
  else {
106
141
  // Added or edited → take the edited node, re-attaching original layout.
107
- finalNodes.push(withOriginalLayout(jsonClone(e), orig));
142
+ finalNodes.push(withOriginalLayout(jsonClone(e), orig, layoutNodesOf(original)));
143
+ if (orig && orig.type === e.type && orig.typeVersion !== e.typeVersion) {
144
+ options.warn?.(`node ${JSON.stringify(e.id)} moved ${e.type}@${String(orig.typeVersion)} -> ${String(e.typeVersion)} `
145
+ + `(the edited node takes the SDK's version; flow-builder-sdk#1167).`);
146
+ }
108
147
  }
109
148
  }
110
149
  result.nodes = finalNodes;
@@ -124,15 +163,25 @@ export function mergeFlow(original, edited, options = {}) {
124
163
  // one already taken elsewhere in the file, gets a fresh id, from ids unused
125
164
  // anywhere in the file. ──
126
165
  result.edges = withFileUniqueEdgeIds(edited.edges ?? [], subflowScopes);
166
+ // ── Placement: a node the edit ADDED has only compile's placeholder, an
167
+ // emission-index slot that can land on an original node. Put it next to
168
+ // its predecessor, collision-free, in the store the
169
+ // original uses — see node-placement.ts. Root scope only: a subflow is
170
+ // taken from `edited` whole, layout included. ──
171
+ placeAddedNodes(finalNodes, result.edges, layoutNodesOf(result), (id) => !origNodes.has(id));
127
172
  // ── Definitions: the (type, version) pairs used by EVERY node in the file —
128
173
  // root and subflows — original winning on conflict (its defs are the
129
174
  // tenant-resolved authority). Selecting by root nodes only dropped the
130
175
  // definitions a subflow-only node needs, and validate refused the result
131
176
  // ("has no matching definition"). ──
132
177
  const allNodes = [...finalNodes, ...subflowScopes.flatMap((sf) => sf?.nodes ?? [])];
133
- result.definitions = mergeDefinitions(original.definitions, edited.definitions, allNodes, options.warn);
134
- // ── Bindings: union by id (original wins), so resources for newly-added nodes
135
- // come from `edited` while the original's stay intact. ──
178
+ const originalNodes = [
179
+ ...(original.nodes ?? []),
180
+ ...Object.values(original.subflows ?? {}).flatMap((sf) => sf?.nodes ?? []),
181
+ ];
182
+ result.definitions = mergeDefinitions(original.definitions, edited.definitions, allNodes, originalNodes, options.warn);
183
+ // ── Bindings: union by what each row binds (original wins), so resources for
184
+ // newly-added nodes come from `edited` while the original's stay intact. ──
136
185
  result.bindings = mergeBindings(original.bindings, edited.bindings);
137
186
  // metadata / solutionId / projectId / layout / runtime were carried by the
138
187
  // initial clone of `original` and are intentionally left untouched.
@@ -147,6 +196,19 @@ export function mergeFlow(original, edited, options = {}) {
147
196
  if (options.baseline && edited.version !== undefined && edited.version !== options.baseline.version) {
148
197
  result.version = edited.version;
149
198
  }
199
+ // ── What the no-edit round trip changed, and what mixing sources broke. ──
200
+ // Under strict, the findings that do not refuse are still warned, so a
201
+ // refused run shows them too.
202
+ const findings = [
203
+ ...(options.baseline ? roundTripFindings(original, options.baseline, result) : []),
204
+ ...mergedFileFindings(result, original),
205
+ ];
206
+ const refusing = options.strict ? findings.filter((f) => f.refuses) : [];
207
+ for (const f of findings)
208
+ if (!refusing.includes(f))
209
+ options.warn?.(f.message);
210
+ if (refusing.length > 0)
211
+ throw new MergeRefusedError(refusing);
150
212
  return result;
151
213
  }
152
214
  /**
@@ -186,8 +248,9 @@ function indexDefinitions(defs) {
186
248
  }
187
249
  return ix;
188
250
  }
189
- function mergeDefinitions(orig, edited, nodes, warn) {
251
+ function mergeDefinitions(orig, edited, nodes, originalNodes, warn) {
190
252
  const origIx = indexDefinitions(orig);
253
+ const originalPairs = new Set(originalNodes.filter((n) => n.type !== undefined && n.typeVersion !== undefined).map((n) => defKey(n.type, n.typeVersion)));
191
254
  const editedIx = indexDefinitions(edited);
192
255
  const out = new Map();
193
256
  const missing = [];
@@ -207,25 +270,62 @@ function mergeDefinitions(orig, edited, nodes, warn) {
207
270
  const def = origIx.get(key) ?? editedIx.get(key);
208
271
  if (def)
209
272
  out.set(key, def);
210
- else if (!missing.includes(key))
273
+ // A definitionless type is not missing anything.
274
+ else if (!DEFINITIONLESS_NODE_TYPES.has(n.type) && !missing.includes(key))
211
275
  missing.push(key);
212
276
  }
213
277
  for (const key of missing) {
214
- warn?.(`node type ${key} is pinned by a merged node but neither the original nor the edited flow `
215
- + `carries a definition for that exact pair, so the merged file has none; `
216
- + `\`validate\` will report "has no matching definition". `
217
- + `Recompile the edited source against an SDK that bundles ${key}, or align the node's typeVersion `
218
- + `with a definition one of the inputs does carry.`);
278
+ // A pair the original already pinned with no definition still fails
279
+ // `validate`; the warning says the merge inherited it rather than caused it.
280
+ warn?.(originalPairs.has(key)
281
+ ? `node type ${key} is pinned by a merged node and already had no definition in the original, `
282
+ + `and the edited flow carries none either, so the merged file has none; `
283
+ + `\`validate\` will report "has no matching definition", as it does for the original. `
284
+ + `Recompile the edited source against an SDK that bundles ${key} to add one.`
285
+ : `node type ${key} is pinned by a merged node but neither the original nor the edited flow `
286
+ + `carries a definition for that exact pair, so the merged file has none; `
287
+ + `\`validate\` will report "has no matching definition". `
288
+ + `Recompile the edited source against an SDK that bundles ${key}, or align the node's typeVersion `
289
+ + `with a definition one of the inputs does carry.`);
219
290
  }
220
291
  return [...out.values()].map((d) => jsonClone(d));
221
292
  }
293
+ /**
294
+ * The original's rows, then each edited row that binds something the original
295
+ * does not (a resource of a node the user added, or one an edit re-pointed).
296
+ *
297
+ * Original wins on the same identity: its rows are the tenant-resolved
298
+ * authority, and a canvas row can carry values (a `default`, a folder) the
299
+ * compile does not. An added row keeps its id unless an original row already
300
+ * holds it, in which case it gets a fresh one — ids are unreferenced labels, but
301
+ * two rows with one id are not a valid file.
302
+ *
303
+ * Rows no surviving node uses are NOT pruned. The file has no uniform
304
+ * node-to-row reference to decide "unused" by: an in-solution Flow is named by
305
+ * the node TYPE's suffix, a connection by an id in the node's inputs, a published
306
+ * process by a folder-qualified key that no node field spells. A wrong prune
307
+ * drops a row a kept node needs, and the run fails to resolve its resource; an
308
+ * orphan row is accepted by `validate` and ignored at run time.
309
+ */
222
310
  function mergeBindings(orig, edited) {
223
- const byId2 = new Map();
224
- for (const b of edited ?? [])
225
- if (b.id)
226
- byId2.set(b.id, b);
227
- for (const b of orig ?? [])
228
- if (b.id)
229
- byId2.set(b.id, b); // original wins
230
- return [...byId2.values()].map((b) => jsonClone(b));
311
+ const out = (orig ?? []).map((b) => jsonClone(b));
312
+ const identities = new Set(out.map(bindingIdentity));
313
+ const ids = new Set(out.map((b) => b.id).filter((id) => typeof id === 'string'));
314
+ for (const b of edited ?? []) {
315
+ const identity = bindingIdentity(b);
316
+ if (identities.has(identity))
317
+ continue;
318
+ identities.add(identity);
319
+ const row = jsonClone(b);
320
+ if (typeof row.id === 'string' && ids.has(row.id)) {
321
+ let n = 2;
322
+ while (ids.has(`${row.id}${n}`))
323
+ n++;
324
+ row.id = `${row.id}${n}`;
325
+ }
326
+ if (typeof row.id === 'string')
327
+ ids.add(row.id);
328
+ out.push(row);
329
+ }
330
+ return out;
231
331
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * node-placement — where compile puts a node it has no position for, and where
3
+ * merge moves one the edit added.
4
+ *
5
+ * Compile gives every node a placeholder from its emission index,
6
+ * `x = 256 + 288 × index, y = 144` (`+160` inside a container). Merge replaces
7
+ * that placeholder on each node the edit added:
8
+ *
9
+ * - next to a positioned predecessor in the same container: one slot right;
10
+ * - else, when the incoming edge comes from the node's own container (the first
11
+ * node of a loop body): at the body origin, inside the container's padding;
12
+ * - else, one slot left of a positioned successor in the same container;
13
+ * - then the nearest free spot around that candidate.
14
+ *
15
+ * Example: the edit inserts `auditLog` between `extractInvoice (448,144)` and
16
+ * `routeInvoice (640,144)`. The candidate `(640,144)` is taken, so `auditLog`
17
+ * goes to the first free spot, `(640,264)`.
18
+ *
19
+ * A child's position is relative to its container, as on the canvas.
20
+ *
21
+ * The position goes where the original stores positions: `layout.nodes[id]`
22
+ * when it has a `layout.nodes` map, otherwise `node.ui`. Positions are read the
23
+ * way the designer reads them (flow-workbench `flow-schema`
24
+ * `workflow-format-conversion.ts` `mergeLayout`): a `layout.nodes[id]` entry
25
+ * wins over `node.ui`, field by field.
26
+ *
27
+ * `findFreePosition` is a copy of flow-workbench `flow-core`
28
+ * `layout/collision-detection.ts` (same 24 px padding, 40 px step, 50 rings,
29
+ * offset order, 96 px default size, sticky notes excluded), because the SDK may
30
+ * not import `@uipath/flow-core` (lint FB015).
31
+ */
32
+ /** A position on the canvas. */
33
+ export interface Point {
34
+ x: number;
35
+ y: number;
36
+ }
37
+ /**
38
+ * Compile's placeholder for the `index`-th node it emits.
39
+ *
40
+ * @param index - The node's emission index in the file.
41
+ * @param nested - Whether the node sits inside a container.
42
+ * @returns The placeholder position.
43
+ */
44
+ export declare function placeholderPosition(index: number, nested: boolean): Point;
45
+ /**
46
+ * Whether `p` is a compile placeholder rather than a position someone chose.
47
+ * An author `.layout()` entry that happens to hit the same grid point reads as
48
+ * a placeholder; that is the one ambiguity.
49
+ */
50
+ export declare function isPlaceholderPosition(p: Point | undefined): boolean;
51
+ /** A node's `ui` (or its `layout.nodes` entry): the fields placement reads. */
52
+ interface Ui {
53
+ position?: Point;
54
+ size?: {
55
+ width?: number;
56
+ height?: number;
57
+ };
58
+ [k: string]: unknown;
59
+ }
60
+ /** A node as merge holds it; `ui` is untyped in the file model. */
61
+ interface PlacedNode {
62
+ id: string;
63
+ type?: string;
64
+ parentId?: string;
65
+ ui?: unknown;
66
+ [k: string]: unknown;
67
+ }
68
+ /**
69
+ * Give each node the edit added a position next to its predecessor, in place.
70
+ *
71
+ * A node is placed when it is not in the original and its position is a
72
+ * compile placeholder (an author `.layout()` position is kept). Its candidate,
73
+ * in order: one slot right of the first predecessor in the same container that
74
+ * already has a position (an original node, or an added node placed before it);
75
+ * the body origin, when an edge enters it from its own container; one slot left
76
+ * of a positioned successor in the same container. With none, the node keeps
77
+ * its placeholder, and placed nodes avoid it.
78
+ *
79
+ * Where the result is written follows the file: when the original stores
80
+ * positions in `layout.nodes` (every canvas-saved file does), the position goes
81
+ * there and the placeholder `node.ui` is dropped, so the file has one store;
82
+ * otherwise `node.ui.position` is updated.
83
+ *
84
+ * @param nodes - The merged root nodes (mutated).
85
+ * @param edges - The merged root edges.
86
+ * @param layoutNodes - The merged file's `layout.nodes`, or undefined when the original has none (mutated).
87
+ * @param isAdded - Whether a node id is new in this merge.
88
+ */
89
+ export declare function placeAddedNodes(nodes: PlacedNode[], edges: ReadonlyArray<{
90
+ sourceNodeId: string;
91
+ targetNodeId: string;
92
+ }>, layoutNodes: Record<string, Ui> | undefined, isAdded: (id: string) => boolean): void;
93
+ export {};
@@ -0,0 +1,163 @@
1
+ /**
2
+ * node-placement — where compile puts a node it has no position for, and where
3
+ * merge moves one the edit added.
4
+ *
5
+ * Compile gives every node a placeholder from its emission index,
6
+ * `x = 256 + 288 × index, y = 144` (`+160` inside a container). Merge replaces
7
+ * that placeholder on each node the edit added:
8
+ *
9
+ * - next to a positioned predecessor in the same container: one slot right;
10
+ * - else, when the incoming edge comes from the node's own container (the first
11
+ * node of a loop body): at the body origin, inside the container's padding;
12
+ * - else, one slot left of a positioned successor in the same container;
13
+ * - then the nearest free spot around that candidate.
14
+ *
15
+ * Example: the edit inserts `auditLog` between `extractInvoice (448,144)` and
16
+ * `routeInvoice (640,144)`. The candidate `(640,144)` is taken, so `auditLog`
17
+ * goes to the first free spot, `(640,264)`.
18
+ *
19
+ * A child's position is relative to its container, as on the canvas.
20
+ *
21
+ * The position goes where the original stores positions: `layout.nodes[id]`
22
+ * when it has a `layout.nodes` map, otherwise `node.ui`. Positions are read the
23
+ * way the designer reads them (flow-workbench `flow-schema`
24
+ * `workflow-format-conversion.ts` `mergeLayout`): a `layout.nodes[id]` entry
25
+ * wins over `node.ui`, field by field.
26
+ *
27
+ * `findFreePosition` is a copy of flow-workbench `flow-core`
28
+ * `layout/collision-detection.ts` (same 24 px padding, 40 px step, 50 rings,
29
+ * offset order, 96 px default size, sticky notes excluded), because the SDK may
30
+ * not import `@uipath/flow-core` (lint FB015).
31
+ */
32
+ // Sticky notes may overlap anything (flow-core skips them in collisions).
33
+ import { STICKY_NOTE_TYPE } from './core/definitionless-node-types.js';
34
+ /** Where compile's placeholder row starts, and its spacing. */
35
+ const PLACEHOLDER = { x0: 256, dx: 288, y0: 144, nestedDy: 160 };
36
+ /** The default node box the canvas assumes (flow-core `resolveWidth`/`resolveHeight`). */
37
+ const DEFAULT_SIZE = 96;
38
+ /** One slot to the right of a node: its width plus an equal gap, the spacing of a canvas row. */
39
+ const SLOT_DX = 192;
40
+ /** flow-core `findFreePosition` defaults. */
41
+ const PADDING = 24;
42
+ const STEP = 40;
43
+ const MAX_RINGS = 50;
44
+ /**
45
+ * Where a container's first body node goes, relative to the container: inside
46
+ * its left and top padding (flow-core `defaultContainerFit`: 144 left, 96 top).
47
+ */
48
+ const BODY_ORIGIN = { x: 144, y: 96 };
49
+ /**
50
+ * Compile's placeholder for the `index`-th node it emits.
51
+ *
52
+ * @param index - The node's emission index in the file.
53
+ * @param nested - Whether the node sits inside a container.
54
+ * @returns The placeholder position.
55
+ */
56
+ export function placeholderPosition(index, nested) {
57
+ return { x: PLACEHOLDER.x0 + index * PLACEHOLDER.dx, y: PLACEHOLDER.y0 + (nested ? PLACEHOLDER.nestedDy : 0) };
58
+ }
59
+ /**
60
+ * Whether `p` is a compile placeholder rather than a position someone chose.
61
+ * An author `.layout()` entry that happens to hit the same grid point reads as
62
+ * a placeholder; that is the one ambiguity.
63
+ */
64
+ export function isPlaceholderPosition(p) {
65
+ if (!p)
66
+ return false;
67
+ const k = (p.x - PLACEHOLDER.x0) / PLACEHOLDER.dx;
68
+ return Number.isInteger(k) && k >= 0 && (p.y === PLACEHOLDER.y0 || p.y === PLACEHOLDER.y0 + PLACEHOLDER.nestedDy);
69
+ }
70
+ const overlaps = (a, b) => !(a.x + a.width + PADDING <= b.x || b.x + b.width + PADDING <= a.x
71
+ || a.y + a.height + PADDING <= b.y || b.y + b.height + PADDING <= a.y);
72
+ /** flow-core `findFreePosition`: the candidate, else the first free spot on rings around it. */
73
+ function findFreePosition(candidate, size, others) {
74
+ const free = (p) => !others.some((o) => overlaps({ ...p, ...size }, o));
75
+ if (free(candidate))
76
+ return candidate;
77
+ for (let d = STEP; d <= STEP * MAX_RINGS; d += STEP) {
78
+ for (const [dx, dy] of [[d, 0], [0, d], [d, d], [-d, 0], [0, -d], [-d, -d]]) {
79
+ const p = { x: candidate.x + dx, y: candidate.y + dy };
80
+ if (free(p))
81
+ return p;
82
+ }
83
+ }
84
+ return candidate;
85
+ }
86
+ /**
87
+ * Give each node the edit added a position next to its predecessor, in place.
88
+ *
89
+ * A node is placed when it is not in the original and its position is a
90
+ * compile placeholder (an author `.layout()` position is kept). Its candidate,
91
+ * in order: one slot right of the first predecessor in the same container that
92
+ * already has a position (an original node, or an added node placed before it);
93
+ * the body origin, when an edge enters it from its own container; one slot left
94
+ * of a positioned successor in the same container. With none, the node keeps
95
+ * its placeholder, and placed nodes avoid it.
96
+ *
97
+ * Where the result is written follows the file: when the original stores
98
+ * positions in `layout.nodes` (every canvas-saved file does), the position goes
99
+ * there and the placeholder `node.ui` is dropped, so the file has one store;
100
+ * otherwise `node.ui.position` is updated.
101
+ *
102
+ * @param nodes - The merged root nodes (mutated).
103
+ * @param edges - The merged root edges.
104
+ * @param layoutNodes - The merged file's `layout.nodes`, or undefined when the original has none (mutated).
105
+ * @param isAdded - Whether a node id is new in this merge.
106
+ */
107
+ export function placeAddedNodes(nodes, edges, layoutNodes, isAdded) {
108
+ const byId = new Map(nodes.map((n) => [n.id, n]));
109
+ const uiOf = (n) => {
110
+ const entry = layoutNodes?.[n.id];
111
+ const own = n.ui;
112
+ return entry ? { ...own, ...entry } : own;
113
+ };
114
+ const sizeOf = (n) => ({
115
+ width: uiOf(n)?.size?.width ?? DEFAULT_SIZE,
116
+ height: uiOf(n)?.size?.height ?? DEFAULT_SIZE,
117
+ });
118
+ const pending = new Set(nodes.filter((n) => isAdded(n.id) && n.type !== STICKY_NOTE_TYPE && isPlaceholderPosition(uiOf(n)?.position)).map((n) => n.id));
119
+ const positioned = (id) => {
120
+ const n = byId.get(id);
121
+ return n !== undefined && !pending.has(id) && uiOf(n)?.position !== undefined;
122
+ };
123
+ // Place in waves, so a run of added nodes chains off the first one placed.
124
+ for (let progress = true; progress && pending.size > 0;) {
125
+ progress = false;
126
+ for (const id of [...pending]) {
127
+ const n = byId.get(id);
128
+ const sameScope = (other) => byId.get(other)?.parentId === n.parentId;
129
+ const pred = edges.find((e) => e.targetNodeId === id && sameScope(e.sourceNodeId) && positioned(e.sourceNodeId));
130
+ const entersBody = !pred && n.parentId !== undefined && byId.has(n.parentId)
131
+ && edges.some((e) => e.targetNodeId === id && e.sourceNodeId === n.parentId);
132
+ const succ = pred || entersBody ? undefined
133
+ : edges.find((e) => e.sourceNodeId === id && sameScope(e.targetNodeId) && positioned(e.targetNodeId));
134
+ let candidate;
135
+ if (pred || succ) {
136
+ const anchor = uiOf(byId.get(pred?.sourceNodeId ?? succ.targetNodeId)).position;
137
+ candidate = { x: anchor.x + (pred ? SLOT_DX : -SLOT_DX), y: anchor.y };
138
+ }
139
+ else if (entersBody) {
140
+ candidate = { ...BODY_ORIGIN };
141
+ }
142
+ else {
143
+ continue;
144
+ }
145
+ // Still-pending nodes count at their placeholder: one that never finds an
146
+ // anchor stays there, and nothing may land on it.
147
+ const others = nodes
148
+ .filter((o) => o.id !== id && o.parentId === n.parentId && o.type !== STICKY_NOTE_TYPE && uiOf(o)?.position !== undefined)
149
+ .map((o) => ({ ...uiOf(o).position, ...sizeOf(o) }));
150
+ const position = findFreePosition(candidate, sizeOf(n), others);
151
+ const ui = { ...uiOf(n), position };
152
+ if (layoutNodes) {
153
+ layoutNodes[id] = ui;
154
+ delete n.ui;
155
+ }
156
+ else {
157
+ n.ui = ui;
158
+ }
159
+ pending.delete(id);
160
+ progress = true;
161
+ }
162
+ }
163
+ }
@@ -7,6 +7,7 @@
7
7
  * codes and messages unchanged.
8
8
  */
9
9
  import { Expr } from '../../../core/expr.js';
10
+ import { wireExprBody } from '../../../core/wire-expr.js';
10
11
  import { jsonClone } from '../../../core/json-file.js';
11
12
  import { VARIANT_OPERATION } from './spec.js';
12
13
  import { checkTransform } from './check.js';
@@ -139,7 +140,7 @@ function liftTransform(node, variant, ctx) {
139
140
  return {
140
141
  kind: 'transform',
141
142
  inputs: {
142
- collection: (raw !== undefined ? new Expr(raw.startsWith('=js:') ? raw.slice(4) : raw) : undefined),
143
+ collection: (raw !== undefined ? new Expr(wireExprBody(raw)) : undefined),
143
144
  ...(variant ? { variant } : {}),
144
145
  operations: operations,
145
146
  },
@@ -20,7 +20,9 @@
20
20
  * folder and every folder above it, then `PATH`.
21
21
  *
22
22
  * The block brings its own imports, `existsSync` among them, and defines
23
- * `run(verb, args)`; the pipeline body uses both. Nothing in it names a path on
23
+ * `frontDoor` (`{ via: 'uip' | 'sdk', file, prefix }`) and `run(verb, args)`;
24
+ * the pipeline body uses them. `via` says which door won, because on Windows
25
+ * `file` is node.exe or a full path, never the bare name. Nothing in it names a path on
24
26
  * the machine that generated it, so a pipeline stays relocatable.
25
27
  *
26
28
  * Example, for `kind = 'flow'` on a machine whose `uip` has the commands:
@@ -61,7 +63,7 @@ function uipFrontDoor() {
61
63
  return undefined; // a uip shim that cannot be read: use the SDK instead
62
64
  }
63
65
  const probe = spawnSync(uip.file, [...uip.args, 'maestro', KIND, 'compile', '--help'], { stdio: 'ignore' });
64
- return !probe.error && probe.status === 0 ? { file: uip.file, prefix: [...uip.args, 'maestro', KIND] } : undefined;
66
+ return !probe.error && probe.status === 0 ? { via: 'uip', file: uip.file, prefix: [...uip.args, 'maestro', KIND] } : undefined;
65
67
  }
66
68
 
67
69
  // 2. The SDK's flow-sdk bin, where \`npx --no-install flow-sdk\` looked for it.
@@ -78,7 +80,7 @@ function sdkFrontDoor() {
78
80
  const local = bins.map((bin) => join(bin, 'flow-sdk')).find((p) => existsSync(p));
79
81
  sdk = { file: local ?? 'flow-sdk', args: [] };
80
82
  }
81
- return { file: sdk.file, prefix: [...sdk.args, KIND] };
83
+ return { via: 'sdk', file: sdk.file, prefix: [...sdk.args, KIND] };
82
84
  }
83
85
 
84
86
  const frontDoor = uipFrontDoor() ?? sdkFrontDoor();