@uipath/maestro-builder-sdk 6.30.2 → 6.30.3

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.
@@ -97,5 +97,24 @@ export declare function inlineAgentResources(files: InlineAgentFiles): BindingsV
97
97
  * connection row's `resource` as `connection`.
98
98
  */
99
99
  export declare function mergeInlineAgentResources(existing: BindingsV2Resource[], fresh: BindingsV2Resource[]): BindingsV2Resource[];
100
+ /**
101
+ * A project's resource rows with its inline agents' rows re-derived, the way
102
+ * compile writes them into `bindings_v2.json` (#962), and used by merge when it
103
+ * carries an agent's resource change (#1221).
104
+ *
105
+ * Each agent's rows come from its directory as it now stands
106
+ * ({@link inlineAgentResources}). The rows a REMOVED resource declared go with
107
+ * it: refresh never retracts a row, so an index the flow dropped would stay
108
+ * declared and a deploy could still fail on it. They are generated by the same
109
+ * function, so they match exactly, and a row a remaining resource still
110
+ * declares is kept. Connection rows are not touched here: compile re-derives
111
+ * them from the flow ({@link connectionResources}).
112
+ *
113
+ * @param resources - The project's current rows.
114
+ * @param agents - Every inline agent's files, as they now stand.
115
+ * @param removedResources - The `resource.json` bodies of the resources removed.
116
+ * @returns The rows to write.
117
+ */
118
+ export declare function refreshInlineAgentRows(resources: BindingsV2Resource[], agents: InlineAgentFiles[], removedResources: unknown[]): BindingsV2Resource[];
100
119
  /** Read one inline agent's directory the way refresh does; `undefined` when it has no readable `agent.json`. */
101
120
  export declare function readInlineAgentFiles(agentDir: string): InlineAgentFiles | undefined;
@@ -507,6 +507,35 @@ export function mergeInlineAgentResources(existing, fresh) {
507
507
  }
508
508
  return out;
509
509
  }
510
+ /**
511
+ * A project's resource rows with its inline agents' rows re-derived, the way
512
+ * compile writes them into `bindings_v2.json` (#962), and used by merge when it
513
+ * carries an agent's resource change (#1221).
514
+ *
515
+ * Each agent's rows come from its directory as it now stands
516
+ * ({@link inlineAgentResources}). The rows a REMOVED resource declared go with
517
+ * it: refresh never retracts a row, so an index the flow dropped would stay
518
+ * declared and a deploy could still fail on it. They are generated by the same
519
+ * function, so they match exactly, and a row a remaining resource still
520
+ * declares is kept. Connection rows are not touched here: compile re-derives
521
+ * them from the flow ({@link connectionResources}).
522
+ *
523
+ * @param resources - The project's current rows.
524
+ * @param agents - Every inline agent's files, as they now stand.
525
+ * @param removedResources - The `resource.json` bodies of the resources removed.
526
+ * @returns The rows to write.
527
+ */
528
+ export function refreshInlineAgentRows(resources, agents, removedResources) {
529
+ const agentRows = agents.map((files) => inlineAgentResources(files));
530
+ const live = new Set(agentRows.flat().map((r) => `${r.resource}\u0000${r.key}`));
531
+ const stale = new Set(inlineAgentResources({ agent: {}, resources: removedResources, features: [] })
532
+ .filter((r) => r.resource !== 'connection' && !live.has(`${r.resource}\u0000${r.key}`))
533
+ .map((r) => `${r.resource}\u0000${r.key}`));
534
+ let out = resources.filter((r) => !stale.has(`${r.resource}\u0000${r.key}`));
535
+ for (const rows of agentRows)
536
+ out = mergeInlineAgentResources(out, rows);
537
+ return out;
538
+ }
510
539
  /** Read one inline agent's directory the way refresh does; `undefined` when it has no readable `agent.json`. */
511
540
  export function readInlineAgentFiles(agentDir) {
512
541
  let agent;
@@ -27,10 +27,11 @@ import { check } from './check.js';
27
27
  import { flowCheckOpts, openCliLibrary } from './cli-library.js';
28
28
  import { Bindings } from './bindings.js';
29
29
  import { flagValue, runWhenInvokedDirectly } from './cli-run.js';
30
- import { connectionResources, inlineAgentResources, mergeBindingsV2, mergeInlineAgentResources, readBindingsV2, readInlineAgentFiles, } from './bindings-v2.js';
30
+ import { connectionResources, inlineAgentResources, mergeBindingsV2, readBindingsV2, refreshInlineAgentRows, readInlineAgentFiles, } from './bindings-v2.js';
31
31
  import { ensureTypeScriptRuntime } from './node-runtime.js';
32
32
  import { defaultBindingsFile } from './workdir.js';
33
33
  import { isWithin } from './path-within.js';
34
+ import { resourceSourcesOfOtherFlows } from './project-resource-users.js';
34
35
  import { BindingRefusal } from './core/binding-messages.js';
35
36
  import { ByoaRefusal } from './core/byoa.js';
36
37
  export async function run(argv) {
@@ -225,26 +226,15 @@ export async function run(argv) {
225
226
  // requires every one of them, so leaving them to a refresh the SDK route
226
227
  // never runs made every agent with a deployed-resource tool, a context index
227
228
  // or an app escalation fail validate with `AgentValidationDrift`.
228
- const agentRows = agentDirs
229
+ const agentFiles = agentDirs
229
230
  .map((dir) => readInlineAgentFiles(dir))
230
- .filter((files) => files !== undefined)
231
- .map((files) => inlineAgentResources(files));
232
- // The rows a PRUNED resource declared go with it: refresh never retracts a
233
- // row, so an index the flow dropped would stay declared and a deploy could
234
- // still fail on it. Generated by the same function, so they match exactly,
235
- // and kept when a resource that still exists declares the same row.
236
- // Connection rows are skipped: every one is re-derived from the flow below.
237
- const live = new Set(agentRows.flat().map((r) => `${r.resource}\u0000${r.key}`));
238
- const stale = new Set(inlineAgentResources({ agent: {}, resources: removedResources, features: [] })
239
- .filter((r) => r.resource !== 'connection' && !live.has(`${r.resource}\u0000${r.key}`))
240
- .map((r) => `${r.resource}\u0000${r.key}`));
231
+ .filter((files) => files !== undefined);
232
+ const agentRows = agentFiles.map((files) => inlineAgentResources(files));
241
233
  // Nothing to declare and nothing already declared — do not create an empty
242
234
  // file for a flow that has no resources at all.
243
235
  if (connections.length > 0 || existing !== undefined || agentRows.some((rows) => rows.length > 0)) {
244
236
  const merged = mergeBindingsV2(existing, connections);
245
- merged.resources = merged.resources.filter((r) => !stale.has(`${r.resource}\u0000${r.key}`));
246
- for (const rows of agentRows)
247
- merged.resources = mergeInlineAgentResources(merged.resources, rows);
237
+ merged.resources = refreshInlineAgentRows(merged.resources, agentFiles, removedResources);
248
238
  writeJsonFile(sidecarPath, merged);
249
239
  const derived = new Set([...connections, ...agentRows.flat()].map((r) => `${r.resource}\u0000${r.key}`));
250
240
  const agentCount = merged.resources.filter((r) => r.resource !== 'connection' && derived.has(`${r.resource}\u0000${r.key}`)).length;
@@ -260,42 +250,3 @@ function serializeWarningDiagnostic(w) {
260
250
  return { level: 'warning', code: w.code, message: w.message };
261
251
  }
262
252
  runWhenInvokedDirectly(import.meta.url, 'compile', run);
263
- /**
264
- * The `inputs.source` uuids the OTHER `.flow` files in `dir` reference, each
265
- * mapped to the first file that does. `dir` is the namespace sidecar paths are
266
- * relative to, so these are the agent resources another flow still uses. The
267
- * flow being written (`self`) is left out: what it no longer references is what
268
- * pruning is for. Subflow bodies are included; an unreadable file contributes
269
- * nothing.
270
- */
271
- function resourceSourcesOfOtherFlows(dir, self) {
272
- const users = new Map();
273
- let entries;
274
- try {
275
- entries = readdirSync(dir).filter((f) => f.endsWith('.flow') && resolve(dir, f) !== self);
276
- }
277
- catch {
278
- return users;
279
- }
280
- for (const f of entries) {
281
- let flow;
282
- try {
283
- flow = JSON.parse(readFileSync(join(dir, f), 'utf8'));
284
- }
285
- catch {
286
- continue;
287
- }
288
- const walk = (scope) => {
289
- const s = scope;
290
- for (const n of Array.isArray(s?.nodes) ? s.nodes : []) {
291
- const src = n?.inputs?.source;
292
- if (typeof src === 'string' && !users.has(src))
293
- users.set(src, f);
294
- }
295
- if (s?.subflows && typeof s.subflows === 'object')
296
- Object.values(s.subflows).forEach(walk);
297
- };
298
- walk(flow);
299
- }
300
- return users;
301
- }
@@ -150,7 +150,8 @@ if (!existsSync(BASELINE)) {
150
150
  if (existsSync(BASELINE)) args.push('--baseline', BASELINE);
151
151
  run('merge', args);
152
152
  console.log('\\n>>> Wrote ' + ${JSON.stringify(base + '.merged.flow')}
153
- + '. Replace the original with it, together with every <source>/ file merge reports writing beside it.');
153
+ + '. Replace the original with it, together with every <source>/ file merge reports writing beside it,'
154
+ + ' and delete every directory merge lists under "remove".');
154
155
  }
155
156
  `;
156
157
  }
package/dist/merge-cli.js CHANGED
@@ -30,14 +30,24 @@
30
30
  * compiled the side itself (`--baseline-from`, an edited `.flow.ts`), which keeps
31
31
  * the sidecars in memory; what merge cannot carry is warned, and refused under
32
32
  * `--strict`. See merge-sidecars.ts.
33
+ *
34
+ * Agent resources (#1221): a context or tool the edit adds or changes is written
35
+ * beside the merged output too. One the edit REMOVES is listed, one
36
+ * `flow-merge: remove <dir>` line each (and `removed` in `--json`), because
37
+ * copying the candidate over the project cannot delete a file: delete those
38
+ * directories from the project. When the original sits in a Flow project
39
+ * (`project.uiproj`), the project's `bindings_v2.json` is re-derived for the
40
+ * changed agents, as compile does, and written beside the merged output.
33
41
  */
34
42
  import { reportResult } from './cli-result.js';
35
43
  import { writeJsonFile } from './core/json-file.js';
36
44
  import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
45
+ import { readBindingsV2, readInlineAgentFiles, refreshInlineAgentRows } from './bindings-v2.js';
37
46
  import { basename, dirname, join, resolve } from 'node:path';
38
47
  import { MergeRefusedError, mergeFlowWithSidecars } from './merge.js';
39
48
  import { sidecarSetFromList } from './merge-sidecars.js';
40
49
  import { isWithin } from './path-within.js';
50
+ import { resourceSourcesOfOtherFlows } from './project-resource-users.js';
41
51
  import { flagValue, runWhenInvokedDirectly } from './cli-run.js';
42
52
  import { ensureTypeScriptRuntime } from './node-runtime.js';
43
53
  import { flowCheckOpts, openCliLibrary } from './cli-library.js';
@@ -78,6 +88,71 @@ export function sidecarsBeside(flowPath) {
78
88
  return files;
79
89
  };
80
90
  }
91
+ /**
92
+ * The project's `bindings_v2.json` after merge's resource changes, or `undefined`
93
+ * when the original is not in a Flow project (`project.uiproj`), no agent
94
+ * resource changed, the file already holds the resulting rows, or the change
95
+ * declares no different row (the rows derived
96
+ * after the change equal those derived from the project as it is, so a changed
97
+ * `resultCount` does not create or rewrite the file).
98
+ *
99
+ * Only the agents whose resources changed are re-derived, each from its
100
+ * directory as it will stand once the candidate is installed: the original's
101
+ * files, with merge's writes over them and its removals taken out.
102
+ */
103
+ function projectBindings(projectDir, writes, removals) {
104
+ if (!existsSync(join(projectDir, 'project.uiproj')))
105
+ return undefined;
106
+ const isResource = (path) => /\/resources\/[^/]+\/resource\.json$/.test(path);
107
+ const agentOf = (path) => path.split('/')[0];
108
+ const changedAgents = new Set([
109
+ ...writes.filter((w) => isResource(w.path)).map((w) => agentOf(w.path)),
110
+ ...removals.map(agentOf),
111
+ ]);
112
+ if (changedAgents.size === 0)
113
+ return undefined;
114
+ const removedBodies = [];
115
+ const agents = [];
116
+ const agentsBefore = [];
117
+ const asResources = (files) => [...files.values()].filter((r) => !!r && typeof r === 'object' && '$resourceType' in r);
118
+ for (const source of changedAgents) {
119
+ const onDisk = readInlineAgentFiles(join(projectDir, source));
120
+ const files = new Map();
121
+ const resourcesDir = join(projectDir, source, 'resources');
122
+ if (existsSync(resourcesDir)) {
123
+ for (const entry of readdirSync(resourcesDir).sort()) {
124
+ try {
125
+ files.set(`${source}/resources/${entry}/resource.json`, JSON.parse(readFileSync(join(resourcesDir, entry, 'resource.json'), 'utf8')));
126
+ }
127
+ catch {
128
+ // Refresh skips an unreadable file the same way.
129
+ }
130
+ }
131
+ }
132
+ agentsBefore.push({ agent: onDisk?.agent ?? {}, resources: asResources(files), features: onDisk?.features ?? [] });
133
+ for (const dir of removals.filter((r) => agentOf(r) === source)) {
134
+ const path = `${dir}/resource.json`;
135
+ if (files.has(path))
136
+ removedBodies.push(files.get(path));
137
+ files.delete(path);
138
+ }
139
+ let agent = onDisk?.agent ?? {};
140
+ for (const w of writes.filter((x) => agentOf(x.path) === source)) {
141
+ if (w.path === `${source}/agent.json`)
142
+ agent = w.json;
143
+ else if (isResource(w.path))
144
+ files.set(w.path, w.json);
145
+ }
146
+ agents.push({ agent, resources: asResources(files), features: onDisk?.features ?? [] });
147
+ }
148
+ const existing = readBindingsV2(join(projectDir, 'bindings_v2.json'));
149
+ const before = refreshInlineAgentRows(existing?.resources ?? [], agentsBefore, []);
150
+ const after = refreshInlineAgentRows(existing?.resources ?? [], agents, removedBodies);
151
+ // Nothing to write when the change declares nothing new, or the file already says it.
152
+ if (JSON.stringify(after) === JSON.stringify(before) || JSON.stringify(after) === JSON.stringify(existing?.resources ?? []))
153
+ return undefined;
154
+ return { version: '2.0', ...existing, resources: after };
155
+ }
81
156
  export async function run(argv) {
82
157
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
83
158
  console.error(USAGE);
@@ -205,11 +280,33 @@ export async function run(argv) {
205
280
  writeJsonFile(target, sidecar.json);
206
281
  console.log(`flow-merge: wrote ${sidecar.path} (carries ${sidecar.carried.join(', ')})`);
207
282
  }
283
+ const originalDir = dirOf(originalPath);
284
+ // A resource another `.flow` in the project still names stays (compile's prune
285
+ // has the same guard): a decompiled canvas agent keeps its `source` directory,
286
+ // so two flows can share it.
287
+ const users = resourceSourcesOfOtherFlows(originalDir, resolve(originalPath));
288
+ const removals = merged.removals.filter((rel) => {
289
+ const user = users.get(basename(rel));
290
+ if (user !== undefined)
291
+ console.log(`flow-merge: kept ${rel}: ${user} still references it`);
292
+ return user === undefined;
293
+ });
294
+ const removed = removals.map((rel) => join(originalDir, rel));
295
+ for (const dir of removed) {
296
+ console.log(`flow-merge: remove ${dir} from the project: the edit removed this agent resource, and copying the candidate cannot delete it`);
297
+ }
298
+ const bindingsV2 = projectBindings(originalDir, merged.sidecars, removals);
299
+ if (bindingsV2 !== undefined) {
300
+ writeJsonFile(join(outDir, 'bindings_v2.json'), bindingsV2);
301
+ console.log('flow-merge: wrote bindings_v2.json (the changed agents\' resource rows, re-derived as compile does)');
302
+ }
208
303
  reportResult({
209
304
  path: resolve(out),
210
305
  nodes: (flow.nodes ?? []).length,
211
306
  edges: (flow.edges ?? []).length,
212
307
  ...(targets.length > 0 ? { sidecars: targets.map((t) => t.target) } : {}),
308
+ ...(removed.length > 0 ? { removed } : {}),
309
+ ...(bindingsV2 !== undefined ? { bindings: join(resolve(outDir), 'bindings_v2.json') } : {}),
213
310
  });
214
311
  return 0;
215
312
  }
@@ -19,6 +19,16 @@ export interface MergeSidecarSources {
19
19
  /** Why {@link baseline} is absent, phrased to finish "merge cannot tell an edit from the round trip: …". */
20
20
  baselineMissing?: string;
21
21
  }
22
+ /** The three flows, for matching resource directories by the node that names them. */
23
+ export interface MergeSidecarFlows {
24
+ original: FlowLike;
25
+ baseline?: FlowLike;
26
+ edited: FlowLike;
27
+ }
28
+ type FlowLike = {
29
+ nodes?: AnyNode[];
30
+ subflows?: Record<string, unknown>;
31
+ } | undefined;
22
32
  /** One file the merged flow needs written beside it, relative to it. */
23
33
  export interface SidecarWrite {
24
34
  path: string;
@@ -64,8 +74,9 @@ export declare function mergeJson3(original: unknown, baseline: unknown, edited:
64
74
  export declare function mergeAgentSidecars(merged: {
65
75
  nodes?: AnyNode[];
66
76
  subflows?: Record<string, unknown>;
67
- }, sources: MergeSidecarSources): {
77
+ }, sources: MergeSidecarSources, flows?: MergeSidecarFlows): {
68
78
  writes: SidecarWrite[];
79
+ removals: string[];
69
80
  findings: MergeFinding[];
70
81
  };
71
82
  export {};
@@ -30,7 +30,7 @@
30
30
  * - no baseline sidecar to compare with (no `--baseline`, or the baseline and the
31
31
  * edited compile were written to one directory, so the second overwrote the
32
32
  * first's `<source>/`), and the edited `agent.json` differs from the original's;
33
- * - a change to the agent's resource files (a context or tool added or removed);
33
+ * - without a baseline sidecar, a change to the agent's resource files;
34
34
  * - an `agent.json` edit on a node that carries its own prompts (an embedded
35
35
  * node: decompile, like Studio Web, reads the node's prompts first, so the
36
36
  * edited file would not be what runs).
@@ -38,6 +38,30 @@
38
38
  * An agent the merged flow adds (no sidecar in the original) gets the edited
39
39
  * compile's files whole.
40
40
  *
41
+ * ## Resources (#1221)
42
+ *
43
+ * A context or tool the author adds, removes or changes is carried too. The
44
+ * three sides name the same resource's directory differently: the canvas by its
45
+ * own id (`resources/d84e2c17-…/`), compile by a uuid derived from the step and
46
+ * the resource (`resources/e3ed6f35-…/`). So resources are matched by IDENTITY,
47
+ * not directory: the id of the flow node whose `inputs.source` names the
48
+ * directory (node ids survive the round trip by construction), or, for a
49
+ * resource no node names, its content without `id`. Then, per resource:
50
+ *
51
+ * - added (in the edited compile, not the baseline): the edited file, written
52
+ * under the directory the merged node names;
53
+ * - changed: the author's delta applied onto the ORIGINAL file, written back
54
+ * under the original directory (the merged node keeps the original `source`,
55
+ * see `mergeFlow`);
56
+ * - removed (in the baseline, not the edited compile): the original directory
57
+ * goes into `removals`. Copying the candidate over the project never deletes
58
+ * a file, so the caller has to delete these.
59
+ *
60
+ * A resource only the original has (one compile does not emit) is left alone.
61
+ * A changed or removed resource that pairs with no original one (the canvas
62
+ * named its node, and its file has fields compile does not write) is reported,
63
+ * and refused under `strict`: guessing would delete or duplicate the wrong one.
64
+ *
41
65
  * Pure: the caller reads the files ({@link SidecarSet}) and writes the result.
42
66
  */
43
67
  import { jsonClone } from './core/json-file.js';
@@ -140,6 +164,100 @@ function resourceChanges(from, to) {
140
164
  ];
141
165
  return out.length > 0 ? out.join('; ') : undefined;
142
166
  }
167
+ /** `<source>/resources/<dir>/resource.json` -> `<dir>`; `undefined` for any other path. */
168
+ function resourceDir(source, path) {
169
+ const prefix = `${source}/resources/`;
170
+ if (!path.startsWith(prefix) || !path.endsWith('/resource.json'))
171
+ return undefined;
172
+ const dir = path.slice(prefix.length, -'/resource.json'.length);
173
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(dir) ? dir : undefined;
174
+ }
175
+ /** Every node of a flow, root and subflows, by the `inputs.source` it names. */
176
+ function nodeIdsBySource(flow) {
177
+ const out = new Map();
178
+ const walk = (scope) => {
179
+ const sc = scope;
180
+ for (const n of Array.isArray(sc?.nodes) ? sc.nodes : []) {
181
+ const src = n?.inputs?.source;
182
+ if (typeof src === 'string' && !out.has(src))
183
+ out.set(src, n.id);
184
+ }
185
+ if (sc?.subflows && typeof sc.subflows === 'object')
186
+ Object.values(sc.subflows).forEach(walk);
187
+ };
188
+ walk(flow);
189
+ return out;
190
+ }
191
+ /** A resource file without the `id` its writer derives from the directory. */
192
+ function withoutId(json) {
193
+ if (!isObject(json))
194
+ return json;
195
+ const { id: _id, ...rest } = json;
196
+ return rest;
197
+ }
198
+ /**
199
+ * One side's resources of the agent at `source`, keyed by identity: `node:<id>`
200
+ * for a resource directory a node of that side's flow names in `inputs.source`,
201
+ * else `content:<the file without id>`.
202
+ */
203
+ function resourcesByIdentity(source, files, flow) {
204
+ const byDir = nodeIdsBySource(flow);
205
+ const out = new Map();
206
+ for (const [path, json] of Object.entries(files)) {
207
+ const dir = resourceDir(source, path);
208
+ if (dir === undefined)
209
+ continue;
210
+ const nodeId = byDir.get(dir);
211
+ out.set(nodeId !== undefined ? `node:${nodeId}` : `content:${stable(withoutId(json))}`, { dir, json });
212
+ }
213
+ return out;
214
+ }
215
+ /**
216
+ * The resource files of the agent at `source` the merged flow needs, and the
217
+ * original resource directories it no longer has (see the module comment).
218
+ */
219
+ function carryResources(source, files, flows) {
220
+ const o = resourcesByIdentity(source, files.original, flows.original);
221
+ const b = resourcesByIdentity(source, files.baseline, flows.baseline);
222
+ const e = resourcesByIdentity(source, files.edited, flows.edited);
223
+ const writes = [];
224
+ const removals = [];
225
+ /** Baseline resources the edit changed or removed that pair with no original resource. */
226
+ const unmatched = [];
227
+ const pathOf = (dir) => `${source}/resources/${dir}/resource.json`;
228
+ for (const key of new Set([...b.keys(), ...e.keys()])) {
229
+ const [ok, bk, ek] = [o.get(key), b.get(key), e.get(key)];
230
+ if (ek !== undefined && bk === undefined) {
231
+ // Added by the author. Its directory is the one the edited (and so the
232
+ // merged) node names; an original resource of the same identity the round
233
+ // trip dropped is updated in place instead.
234
+ if (ok !== undefined)
235
+ continue;
236
+ writes.push({ path: pathOf(ek.dir), json: jsonClone(ek.json), carried: ['added resource'] });
237
+ }
238
+ else if (ek === undefined && bk !== undefined) {
239
+ if (ok !== undefined)
240
+ removals.push(`${source}/resources/${ok.dir}`);
241
+ else
242
+ unmatched.push(pathOf(bk.dir));
243
+ }
244
+ else if (ek !== undefined && bk !== undefined && stable(withoutId(bk.json)) !== stable(withoutId(ek.json))) {
245
+ const changed = [];
246
+ if (ok === undefined) {
247
+ // Writing the edited file would add a second resource beside the
248
+ // original one it was meant to change.
249
+ unmatched.push(pathOf(bk.dir));
250
+ continue;
251
+ }
252
+ // The derived `id` is not the author's: give both compiles the original's,
253
+ // so only the content delta lands (and the original's key order stays).
254
+ const withOrigId = (json) => (isObject(json) && isObject(ok.json) && 'id' in ok.json ? { ...json, id: ok.json.id } : json);
255
+ const json = mergeJson3(ok.json, withOrigId(bk.json), withOrigId(ek.json), changed);
256
+ writes.push({ path: pathOf(ok.dir), json, carried: changed });
257
+ }
258
+ }
259
+ return { writes, removals, unmatched };
260
+ }
143
261
  /** Whether a node carries its prompts itself, so its `agent.json` is not what runs (see decompile's `sidecarAgentInputs`). */
144
262
  function carriesOwnPrompts(node) {
145
263
  const i = node?.inputs ?? {};
@@ -151,8 +269,9 @@ const finding = (message) => ({ kind: 'agent-sidecar', message, refuses: true })
151
269
  * not carry. `merged` is `mergeFlow`'s result. An agent is new when the
152
270
  * original side has no `<source>/agent.json` for it.
153
271
  */
154
- export function mergeAgentSidecars(merged, sources) {
272
+ export function mergeAgentSidecars(merged, sources, flows) {
155
273
  const writes = [];
274
+ const removals = [];
156
275
  const findings = [];
157
276
  for (const [source, node] of agentSources(merged)) {
158
277
  const edited = sources.edited(source);
@@ -202,13 +321,24 @@ export function mergeAgentSidecars(merged, sources) {
202
321
  writes.push({ path: agentPath, json: next, carried: changed });
203
322
  }
204
323
  }
205
- // Resources: report a changed set; the canvas names them by its own ids, so
206
- // the edited files cannot be mapped onto the original's directories.
207
- const res = resourceChanges(base, edited);
208
- if (res) {
209
- findings.push(finding(`agent "${node.id}": the edit changes its resource files (${res}). Merge does not carry resource files; `
210
- + 'the original\'s stay as they are. Make that change on the canvas.'));
324
+ // Resources: carried by identity (#1221), when the three flows are known.
325
+ if (flows === undefined) {
326
+ const res = resourceChanges(base, edited);
327
+ if (res) {
328
+ findings.push(finding(`agent "${node.id}": the edit changes its resource files (${res}). Merge does not carry resource files; `
329
+ + 'the original\'s stay as they are. Make that change on the canvas.'));
330
+ }
331
+ continue;
332
+ }
333
+ const carriedResources = carryResources(source, { original: orig, baseline: base, edited }, flows);
334
+ writes.push(...carriedResources.writes);
335
+ removals.push(...carriedResources.removals);
336
+ for (const path of carriedResources.unmatched) {
337
+ findings.push(finding(`agent "${node.id}": the edit changes or removes the resource ${path}, but merge cannot tell which of the `
338
+ + 'original\'s resource directories it is (no original node has the id of the node that names it, and no '
339
+ + 'original resource file has its content), so the change is NOT carried and the original\'s resource files '
340
+ + 'stay as they are. Make that change on the canvas.'));
211
341
  }
212
342
  }
213
- return { writes, findings };
343
+ return { writes, removals, findings };
214
344
  }
package/dist/merge.d.ts CHANGED
@@ -176,4 +176,5 @@ export declare function mergeFlow(original: AnyFlow, edited: AnyFlow, options?:
176
176
  export declare function mergeFlowWithSidecars(original: AnyFlow, edited: AnyFlow, sidecars: MergeSidecarSources, options?: MergeOptions): {
177
177
  flow: AnyFlow;
178
178
  sidecars: SidecarWrite[];
179
+ removals: string[];
179
180
  };
package/dist/merge.js CHANGED
@@ -140,6 +140,38 @@ function withOriginalLayout(edited, orig, originalLayout) {
140
140
  }
141
141
  return edited;
142
142
  }
143
+ /**
144
+ * Give an edited node the original's `inputs.source` when only the round trip
145
+ * changed it (the baseline's equals the edited one's).
146
+ *
147
+ * `source` names the directory of a node's sidecar files: an agent's
148
+ * `<source>/agent.json`, an agent resource's `<agent>/resources/<source>/`.
149
+ * Decompile keeps an agent's, but compile derives a resource's from the step
150
+ * and the resource, so the canvas's `resources/d84e2c17-…/` comes back as
151
+ * `resources/e3ed6f35-…/`. An edited resource node taken whole would then name
152
+ * a directory the project does not have. The author never writes a resource's
153
+ * `source`, so keeping the original's is the delta rule (#1221); merge-sidecars
154
+ * writes the carried file back under that same directory.
155
+ */
156
+ function withOriginalSource(node, orig, base) {
157
+ const o = orig?.inputs?.source;
158
+ const e = node.inputs?.source;
159
+ if (typeof o !== 'string' || typeof e !== 'string' || o === e || base?.inputs?.source !== e)
160
+ return node;
161
+ return { ...node, inputs: { ...node.inputs, source: o } };
162
+ }
163
+ /** {@link withOriginalSource} over every subflow node the original subflow of the same key also has. */
164
+ function keepOriginalSourcesInSubflows(original, subflows, baseline) {
165
+ for (const [key, sf] of Object.entries(subflows)) {
166
+ const nodes = sf?.nodes;
167
+ if (!Array.isArray(nodes))
168
+ continue;
169
+ const origNodes = byId(original.subflows?.[key]?.nodes);
170
+ const baseNodes = byId(baseline.subflows?.[key]?.nodes);
171
+ for (let i = 0; i < nodes.length; i++)
172
+ nodes[i] = withOriginalSource(nodes[i], origNodes.get(nodes[i].id), baseNodes.get(nodes[i].id));
173
+ }
174
+ }
143
175
  /** The original's `layout.nodes` map, when it has one. */
144
176
  function layoutNodesOf(f) {
145
177
  const nodes = f.layout?.nodes;
@@ -160,9 +192,9 @@ export function mergeFlow(original, edited, options = {}) {
160
192
  */
161
193
  export function mergeFlowWithSidecars(original, edited, sidecars, options = {}) {
162
194
  const { result, findings } = mergeFlowFindings(original, edited, options);
163
- const carried = mergeAgentSidecars(result, sidecars);
195
+ const carried = mergeAgentSidecars(result, sidecars, { original, baseline: options.baseline, edited });
164
196
  reportOrRefuse([...findings, ...carried.findings], options);
165
- return { flow: result, sidecars: carried.writes };
197
+ return { flow: result, sidecars: carried.writes, removals: carried.removals };
166
198
  }
167
199
  /** Warn every finding; under `strict`, throw over the refusing ones (and still warn the rest). */
168
200
  function reportOrRefuse(findings, options) {
@@ -189,7 +221,7 @@ function mergeFlowFindings(original, edited, options) {
189
221
  }
190
222
  else {
191
223
  // Added or edited → take the edited node, re-attaching original layout.
192
- finalNodes.push(withOriginalLayout(jsonClone(e), orig, layoutNodesOf(original)));
224
+ finalNodes.push(withOriginalSource(withOriginalLayout(jsonClone(e), orig, layoutNodesOf(original)), orig, baseNodes.get(e.id)));
193
225
  if (orig && orig.type === e.type && orig.typeVersion !== e.typeVersion) {
194
226
  options.warn?.(`node ${JSON.stringify(e.id)} moved ${e.type}@${String(orig.typeVersion)} -> ${String(e.typeVersion)} `
195
227
  + `(the edited source does not pin ${String(orig.typeVersion)}; keep the { version } pin, `
@@ -203,6 +235,8 @@ function mergeFlowFindings(original, edited, options) {
203
235
  // ── Variables & subflows: the edited flow is authoritative (the user's graph). ──
204
236
  result.variables = jsonClone(edited.variables);
205
237
  result.subflows = jsonClone(edited.subflows ?? {});
238
+ if (options.baseline)
239
+ keepOriginalSourcesInSubflows(original, result.subflows, options.baseline);
206
240
  const subflowScopes = Object.values(result.subflows);
207
241
  // ── Edges: the edited graph's edges define the wiring, under the edited
208
242
  // file's own ids. compile draws every edge id — root AND subflows — from
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The `inputs.source` uuids the OTHER `.flow` files in `dir` reference, each
3
+ * mapped to the first file that does. `dir` is the namespace sidecar paths are
4
+ * relative to, so these are the agent resources another flow still uses. The
5
+ * flow being written (`self`) is left out: what it no longer references is what
6
+ * pruning is for. Subflow bodies are included; an unreadable file contributes
7
+ * nothing.
8
+ */
9
+ export declare function resourceSourcesOfOtherFlows(dir: string, self: string): Map<string, string>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * project-resource-users — which other `.flow` files in a Flow project still
3
+ * use a sidecar directory. Compile's prune and merge's resource removal both
4
+ * must leave such a directory alone: a decompiled canvas agent keeps its
5
+ * `source` directory, so two flows in one project can share it (#1151, #1184).
6
+ */
7
+ import { readdirSync, readFileSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ /**
10
+ * The `inputs.source` uuids the OTHER `.flow` files in `dir` reference, each
11
+ * mapped to the first file that does. `dir` is the namespace sidecar paths are
12
+ * relative to, so these are the agent resources another flow still uses. The
13
+ * flow being written (`self`) is left out: what it no longer references is what
14
+ * pruning is for. Subflow bodies are included; an unreadable file contributes
15
+ * nothing.
16
+ */
17
+ export function resourceSourcesOfOtherFlows(dir, self) {
18
+ const users = new Map();
19
+ let entries;
20
+ try {
21
+ entries = readdirSync(dir).filter((f) => f.endsWith('.flow') && resolve(dir, f) !== self);
22
+ }
23
+ catch {
24
+ return users;
25
+ }
26
+ for (const f of entries) {
27
+ let flow;
28
+ try {
29
+ flow = JSON.parse(readFileSync(join(dir, f), 'utf8'));
30
+ }
31
+ catch {
32
+ continue;
33
+ }
34
+ const walk = (scope) => {
35
+ const s = scope;
36
+ for (const n of Array.isArray(s?.nodes) ? s.nodes : []) {
37
+ const src = n?.inputs?.source;
38
+ if (typeof src === 'string' && !users.has(src))
39
+ users.set(src, f);
40
+ }
41
+ if (s?.subflows && typeof s.subflows === 'object')
42
+ Object.values(s.subflows).forEach(walk);
43
+ };
44
+ walk(flow);
45
+ }
46
+ return users;
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "6.30.2",
3
+ "version": "6.30.3",
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",
@@ -92,5 +92,5 @@
92
92
  "@types/node": "^22.7.0",
93
93
  "esbuild": "^0.28.1"
94
94
  },
95
- "gitref": "dcf5c8c2813ef37768cf148cd82cc27791621715"
95
+ "gitref": "cdac1f9944a11065ba6cc13061a0873f53683358"
96
96
  }