@uipath/maestro-builder-sdk 6.30.1 → 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.
- package/dist/api-index.md +17 -17
- package/dist/api-members.md +76 -76
- package/dist/bindings-v2.d.ts +19 -0
- package/dist/bindings-v2.js +29 -0
- package/dist/check.js +4 -2
- package/dist/compile-cli.js +6 -55
- package/dist/decompile-cli.js +2 -1
- package/dist/flow-sdk.d.ts +3 -2
- package/dist/merge-cli.js +97 -0
- package/dist/merge-sidecars.d.ts +12 -1
- package/dist/merge-sidecars.js +139 -9
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +77 -3
- package/dist/project-resource-users.d.ts +9 -0
- package/dist/project-resource-users.js +47 -0
- package/dist/serialize.js +21 -4
- package/package.json +2 -2
package/dist/check.js
CHANGED
|
@@ -966,8 +966,10 @@ function checkFlow(built, ancestors, opts = {}) {
|
|
|
966
966
|
// subflow read, where the value lives one level deeper
|
|
967
967
|
// (`$vars.<call>.output.<childOutput>`). Every other rule reads only the
|
|
968
968
|
// first two segments, exactly as before.
|
|
969
|
-
for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)(
|
|
970
|
-
const [, name, field
|
|
969
|
+
for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)(?:\??\.([A-Za-z0-9_]+))?((?:\??\.[A-Za-z0-9_]+)*)/g)) {
|
|
970
|
+
const [, name, field] = m;
|
|
971
|
+
// `?.` reads the same path as `.`, and the rules below parse the tail as `.a.b`.
|
|
972
|
+
const rest = m[3].replace(/\?\./g, '.');
|
|
971
973
|
if (!declared.has(name)) {
|
|
972
974
|
const near = [...declared].find((d) => d.toLowerCase() === name.toLowerCase());
|
|
973
975
|
diags.push({
|
package/dist/compile-cli.js
CHANGED
|
@@ -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,
|
|
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
|
|
229
|
+
const agentFiles = agentDirs
|
|
229
230
|
.map((dir) => readInlineAgentFiles(dir))
|
|
230
|
-
.filter((files) => files !== undefined)
|
|
231
|
-
|
|
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
|
|
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
|
-
}
|
package/dist/decompile-cli.js
CHANGED
|
@@ -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/flow-sdk.d.ts
CHANGED
|
@@ -463,8 +463,9 @@ export interface ReturnOptions extends NodeOptions {
|
|
|
463
463
|
*
|
|
464
464
|
* @remarks
|
|
465
465
|
* Like a step name this is identity, not decoration: use {@link NodeOptions.label}
|
|
466
|
-
* for what the canvas shows.
|
|
467
|
-
*
|
|
466
|
+
* for what the canvas shows. Two Ends in one scope asking for `done` with the
|
|
467
|
+
* same values, label and version are one End reached from both paths; Ends that
|
|
468
|
+
* differ are de-duplicated to `done` and `done2`.
|
|
468
469
|
*
|
|
469
470
|
* ```ts
|
|
470
471
|
* .branch('checkTemperature', js`${input('tempF')} > 60`,
|
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
|
}
|
package/dist/merge-sidecars.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/merge-sidecars.js
CHANGED
|
@@ -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
|
|
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:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
@@ -234,6 +268,7 @@ function mergeFlowFindings(original, edited, options) {
|
|
|
234
268
|
// ── Bindings: union by what each row binds (original wins), so resources for
|
|
235
269
|
// newly-added nodes come from `edited` while the original's stay intact. ──
|
|
236
270
|
result.bindings = mergeBindings(original.bindings, edited.bindings);
|
|
271
|
+
result.bindings = withoutRowsKeyedOnlyByKeptNodes(result.bindings, original, edited, options.baseline);
|
|
237
272
|
// metadata / solutionId / projectId / layout / runtime were carried by the
|
|
238
273
|
// initial clone of `original` and are intentionally left untouched.
|
|
239
274
|
//
|
|
@@ -373,3 +408,42 @@ function mergeBindings(orig, edited) {
|
|
|
373
408
|
}
|
|
374
409
|
return out;
|
|
375
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* Drop each added row that the no-edit recompile minted for a resource only
|
|
413
|
+
* nodes kept from the original spell. A row qualifies when the original lacks
|
|
414
|
+
* it, the baseline has it, and its `resourceKey` appears in at least one edited
|
|
415
|
+
* root node, every one of which the merge replaced with the original's: the kept
|
|
416
|
+
* node is the original's, and it reaches the resource without that row.
|
|
417
|
+
*
|
|
418
|
+
* Example: a classic-CLI IxP extract carries its model inline and has no
|
|
419
|
+
* binding rows. It decompiles to `ixpExtract(...)`, whose compile mints
|
|
420
|
+
* `ixpDeployment` `name` / `folderPath` rows keyed by the model id, and an
|
|
421
|
+
* untouched node would carry them into the merged file.
|
|
422
|
+
*
|
|
423
|
+
* A row whose key no edited node spells is kept, as are rows a subflow node or
|
|
424
|
+
* an edited node spells.
|
|
425
|
+
*/
|
|
426
|
+
function withoutRowsKeyedOnlyByKeptNodes(rows, original, edited, baseline) {
|
|
427
|
+
if (!baseline || !rows)
|
|
428
|
+
return rows;
|
|
429
|
+
const origNodes = byId(original.nodes);
|
|
430
|
+
const baseNodes = byId(baseline.nodes);
|
|
431
|
+
const nodes = [
|
|
432
|
+
...(edited.nodes ?? []).map((n) => ({ kept: origNodes.has(n.id) && nodeUnchanged(n, baseNodes.get(n.id)), text: JSON.stringify(n) })),
|
|
433
|
+
...Object.values(edited.subflows ?? {}).flatMap((sf) => (sf?.nodes ?? []).map((n) => ({ kept: false, text: JSON.stringify(n) }))),
|
|
434
|
+
];
|
|
435
|
+
if (!nodes.some((n) => n.kept))
|
|
436
|
+
return rows;
|
|
437
|
+
const fromOriginal = new Set((original.bindings ?? []).map(bindingIdentity));
|
|
438
|
+
const inBaseline = new Set((baseline.bindings ?? []).map(bindingIdentity));
|
|
439
|
+
return rows.filter((b) => {
|
|
440
|
+
const identity = bindingIdentity(b);
|
|
441
|
+
if (fromOriginal.has(identity) || !inBaseline.has(identity))
|
|
442
|
+
return true;
|
|
443
|
+
if (typeof b.resourceKey !== 'string' || b.resourceKey === '')
|
|
444
|
+
return true;
|
|
445
|
+
const spelled = JSON.stringify(b.resourceKey).slice(1, -1);
|
|
446
|
+
const spellers = nodes.filter((n) => n.text.includes(spelled));
|
|
447
|
+
return !(spellers.length > 0 && spellers.every((n) => n.kept));
|
|
448
|
+
});
|
|
449
|
+
}
|
|
@@ -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
|
+
}
|