@uipath/maestro-builder-sdk 6.31.5 → 6.31.7
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/compile-cli.js +131 -8
- package/dist/merge-embedded-agent.d.ts +90 -0
- package/dist/merge-embedded-agent.js +260 -0
- package/dist/merge-roundtrip.d.ts +3 -2
- package/dist/merge-sidecars.js +22 -11
- package/dist/merge.d.ts +12 -1
- package/dist/merge.js +90 -3
- package/dist/project-resource-users.d.ts +8 -0
- package/dist/project-resource-users.js +25 -11
- package/dist/serialize.d.ts +12 -0
- package/dist/serialize.js +15 -7
- package/package.json +2 -2
package/dist/compile-cli.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { reportDiagnostics, reportResult, thrownDiagnostic } from './cli-result.js';
|
|
21
21
|
import { writeJsonFile } from './core/json-file.js';
|
|
22
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
22
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
23
23
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
24
24
|
import { resolveFlowFile, loadBuiltFlow } from './load.js';
|
|
25
25
|
import { serializeWithSidecars, compileOwnsResource } from './serialize.js';
|
|
@@ -32,9 +32,12 @@ import { readBindingsV2, readInlineAgentFiles } from './bindings-v2-files.js';
|
|
|
32
32
|
import { ensureTypeScriptRuntime } from './node-runtime.js';
|
|
33
33
|
import { defaultBindingsFile } from './workdir.js';
|
|
34
34
|
import { isWithin } from './path-within.js';
|
|
35
|
-
import { resourceSourcesOfOtherFlows } from './project-resource-users.js';
|
|
35
|
+
import { flowSources, resourceSourcesOfOtherFlows } from './project-resource-users.js';
|
|
36
|
+
import { fnv1aX4Uuid } from './core/stable-id.js';
|
|
36
37
|
import { BindingRefusal } from './core/binding-messages.js';
|
|
37
38
|
import { ByoaRefusal } from './core/byoa.js';
|
|
39
|
+
/** An agent folder name: a uuid, any case. */
|
|
40
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
38
41
|
export async function run(argv) {
|
|
39
42
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
40
43
|
console.error('usage: compile <flow.ts | BaseName> [-o out.flow] [--library <dir>] [--bindings <file>]');
|
|
@@ -93,10 +96,57 @@ export async function run(argv) {
|
|
|
93
96
|
// (EVENT_BYOA_UNVERIFIED): the check pass above reports each first, and this is
|
|
94
97
|
// the same refusal in the same `✗ [CODE]` shape when that pass was skipped
|
|
95
98
|
// (`--no-check`, or an emit-only project). Nothing is written.
|
|
99
|
+
const out = flagValue(argv, '-o') ?? `${built.name || basename(file).replace(/\.flow\.ts$|\.ts$/, '')}.flow`;
|
|
100
|
+
const outDir = dirname(resolve(out));
|
|
101
|
+
// ONE FOLDER, ONE FLOW (#1184). An agent's `source` is the name of the folder
|
|
102
|
+
// beside the `.flow` that holds its `agent.json` and `resources/`. Decompile
|
|
103
|
+
// keeps a canvas agent's `source` (the sidecar must still resolve), so
|
|
104
|
+
// compiling that source to a SECOND `.flow` in the same project would write
|
|
105
|
+
// into the first flow's folder: its `agent.json` is overwritten with the new
|
|
106
|
+
// flow's agent, and `uip agent refresh` for the new flow declares every
|
|
107
|
+
// resource the first flow keeps there. The product never shares a folder
|
|
108
|
+
// either: Studio Web mints a fresh uuid for an imported flow whose agent the
|
|
109
|
+
// project already owns, copies the folder and restamps `projectId`
|
|
110
|
+
// (flow-workbench MST-14919). So does compile: an agent whose `source`
|
|
111
|
+
// another `.flow` in this Flow project claims is written to its own folder,
|
|
112
|
+
// derived from that `source` and this file's name, so a recompile lands in
|
|
113
|
+
// the same one. Outside a Flow project (`project.uiproj`) nothing is a
|
|
114
|
+
// deliverable: the brownfield loop writes its baseline/edited candidates
|
|
115
|
+
// beside the original on purpose, and they are left as they were.
|
|
116
|
+
//
|
|
117
|
+
// Two rules keep the choice stable across later edits to the project:
|
|
118
|
+
// - The OWNER keeps its folder. A source the existing `out` file already
|
|
119
|
+
// names stays put, even if another `.flow` names it too (one compiled
|
|
120
|
+
// into it before this rule existed): the flow being recompiled is the one
|
|
121
|
+
// that already lives there, so it is never the one moved.
|
|
122
|
+
// - A re-home is STICKY. Once the re-homed folder exists, the step keeps
|
|
123
|
+
// writing there even after the flow that claimed the old folder is
|
|
124
|
+
// deleted or renamed; otherwise the next compile would move back into the
|
|
125
|
+
// old folder (and its leftover resources) and orphan the new one.
|
|
126
|
+
const inProject = existsSync(join(outDir, 'project.uiproj'));
|
|
127
|
+
const claimed = inProject ? sourcesOfOtherFlows(outDir, resolve(out)) : new Map();
|
|
128
|
+
const ownSources = inProject ? new Set(flowSources(readJsonOrUndefined(resolve(out))).map((s) => s.toLowerCase())) : new Set();
|
|
129
|
+
const rehomed = new Map();
|
|
130
|
+
const agentSource = (source, step) => {
|
|
131
|
+
// Only a uuid names a folder (as in Studio Web); anything else is left to
|
|
132
|
+
// the sidecar-path check below rather than read from here.
|
|
133
|
+
if (!inProject || !UUID.test(source))
|
|
134
|
+
return source;
|
|
135
|
+
const key = source.toLowerCase();
|
|
136
|
+
if (ownSources.has(key))
|
|
137
|
+
return source;
|
|
138
|
+
const to = fnv1aX4Uuid(`${key}/${basename(out)}`);
|
|
139
|
+
const owner = claimed.get(key);
|
|
140
|
+
if (owner === undefined)
|
|
141
|
+
return existsSync(join(outDir, to, 'agent.json')) ? to : source;
|
|
142
|
+
if (!rehomed.has(source))
|
|
143
|
+
rehomed.set(source, { from: source, to, step, owner });
|
|
144
|
+
return to;
|
|
145
|
+
};
|
|
96
146
|
const warnings = [];
|
|
97
147
|
let emitted;
|
|
98
148
|
try {
|
|
99
|
-
emitted = serializeWithSidecars(built, { library, bindings, warn: (w) => warnings.push(w) });
|
|
149
|
+
emitted = serializeWithSidecars(built, { library, bindings, warn: (w) => warnings.push(w), agentSource });
|
|
100
150
|
}
|
|
101
151
|
catch (error) {
|
|
102
152
|
for (const w of warnings)
|
|
@@ -119,7 +169,6 @@ export async function run(argv) {
|
|
|
119
169
|
for (const w of warnings)
|
|
120
170
|
console.error(` ⚠ [${w.code}] ${w.message}`);
|
|
121
171
|
diagnostics.push(...warnings.map(serializeWarningDiagnostic));
|
|
122
|
-
const out = flagValue(argv, '-o') ?? `${built.name || basename(file).replace(/\.flow\.ts$|\.ts$/, '')}.flow`;
|
|
123
172
|
// An inline agent is TWO artifacts: the node, and the agent's own configuration
|
|
124
173
|
// at `<source>/agent.json` — which is where flow-v1's serializer points the
|
|
125
174
|
// runtime (`entryPoint: "<source>/agent.json"`, resolved relative to the
|
|
@@ -131,7 +180,6 @@ export async function run(argv) {
|
|
|
131
180
|
//
|
|
132
181
|
// Every sidecar is checked before anything is written; success is reported
|
|
133
182
|
// only once every file is on disk.
|
|
134
|
-
const outDir = dirname(resolve(out));
|
|
135
183
|
const targets = sidecars.map((s) => ({ sidecar: s, target: resolve(outDir, s.path) }));
|
|
136
184
|
const escaping = targets.find(({ target }) => target === outDir || !isWithin(outDir, target));
|
|
137
185
|
if (escaping) {
|
|
@@ -152,6 +200,20 @@ export async function run(argv) {
|
|
|
152
200
|
console.log(`compile: wrote ${sidecar.path}`);
|
|
153
201
|
written.push(target);
|
|
154
202
|
}
|
|
203
|
+
// A re-homed agent's folder starts as a copy of the folder it came from, minus
|
|
204
|
+
// what this compile just wrote (its `agent.json`, the resources it derives)
|
|
205
|
+
// and minus every `resources/<id>/` this flow's nodes do not name — copying
|
|
206
|
+
// those would hand the new flow's agent the old flow's index again, which is
|
|
207
|
+
// the bug. A file already in the new folder is the new flow's own and is kept.
|
|
208
|
+
if (rehomed.size > 0) {
|
|
209
|
+
const referenced = new Set(flowSources(flowJson).map((s) => s.toLowerCase()));
|
|
210
|
+
for (const { from, to, step, owner } of rehomed.values()) {
|
|
211
|
+
const copied = seedFolder(join(outDir, from), join(outDir, to), referenced);
|
|
212
|
+
console.log(`compile: agent "${step}" uses its own folder ${to}: ${from} belongs to ${owner}`
|
|
213
|
+
+ (copied > 0 ? ` (copied ${copied} file(s) from it)` : '')
|
|
214
|
+
+ `. To make this explicit, set source: "${to}" on "${step}" in the .flow.ts.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
155
217
|
reportResult({ path: resolve(out), nodes: flowJson.nodes.length, edges: flowJson.edges.length, sidecars: written });
|
|
156
218
|
// PRUNE the agent resources this compile owns but did not write this time.
|
|
157
219
|
// Compile otherwise only ever ADDS files, and an agent resource is keyed by a
|
|
@@ -176,7 +238,11 @@ export async function run(argv) {
|
|
|
176
238
|
// canvas resources there are still that flow's. A resource another
|
|
177
239
|
// `.flow` beside this one references by `inputs.source` is never pruned.
|
|
178
240
|
const removedResources = [];
|
|
179
|
-
const referencedElsewhere =
|
|
241
|
+
const referencedElsewhere = sourcesOfOtherFlows(outDir, resolve(out));
|
|
242
|
+
// ... and neither is one THIS flow still names: a re-homed agent's folder is
|
|
243
|
+
// seeded with the resources its nodes reference (see `seedFolder`), and a
|
|
244
|
+
// designer resource a node here points at is in use, whatever its type.
|
|
245
|
+
const referencedHere = new Set(flowSources(flowJson).map((s) => s.toLowerCase()));
|
|
180
246
|
const owned = new Set(sidecars
|
|
181
247
|
.filter((s) => /\/resources\/[^/]+\/resource\.json$/.test(s.path))
|
|
182
248
|
.map((s) => resolve(outDir, s.path)));
|
|
@@ -189,9 +255,9 @@ export async function run(argv) {
|
|
|
189
255
|
continue;
|
|
190
256
|
for (const entry of readdirSync(resourcesDir)) {
|
|
191
257
|
const file = join(resourcesDir, entry, 'resource.json');
|
|
192
|
-
if (!existsSync(file) || owned.has(file))
|
|
258
|
+
if (!existsSync(file) || owned.has(file) || referencedHere.has(entry.toLowerCase()))
|
|
193
259
|
continue;
|
|
194
|
-
const user = referencedElsewhere.get(entry);
|
|
260
|
+
const user = referencedElsewhere.get(entry.toLowerCase());
|
|
195
261
|
if (user !== undefined) {
|
|
196
262
|
console.log(`compile: kept ${join('resources', entry, 'resource.json')}: ${user} still references it`);
|
|
197
263
|
continue;
|
|
@@ -251,3 +317,60 @@ function serializeWarningDiagnostic(w) {
|
|
|
251
317
|
return { level: 'warning', code: w.code, message: w.message };
|
|
252
318
|
}
|
|
253
319
|
runWhenInvokedDirectly(import.meta.url, 'compile', run);
|
|
320
|
+
/** A JSON file's contents, or `undefined` when it is missing or unreadable. */
|
|
321
|
+
function readJsonOrUndefined(path) {
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* `resourceSourcesOfOtherFlows`, keyed lower-case: folder names keep their
|
|
331
|
+
* case, claims must not. The first file to claim a source wins, as there.
|
|
332
|
+
*/
|
|
333
|
+
function sourcesOfOtherFlows(dir, self) {
|
|
334
|
+
const users = new Map();
|
|
335
|
+
for (const [src, f] of resourceSourcesOfOtherFlows(dir, self)) {
|
|
336
|
+
if (!users.has(src.toLowerCase()))
|
|
337
|
+
users.set(src.toLowerCase(), f);
|
|
338
|
+
}
|
|
339
|
+
return users;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Copy agent folder `from` into `to`, for an agent re-homed off a folder another
|
|
343
|
+
* flow owns. Skipped: the top-level `agent.json` (compile writes its own, with
|
|
344
|
+
* the new identity), any `resources/<id>/` whose id is not in `referenced`, any
|
|
345
|
+
* file already present in `to`, and anything that is not a plain file or
|
|
346
|
+
* folder. Returns how many files were copied; a missing `from` copies nothing.
|
|
347
|
+
*/
|
|
348
|
+
function seedFolder(from, to, referenced) {
|
|
349
|
+
let copied = 0;
|
|
350
|
+
const walk = (rel) => {
|
|
351
|
+
let entries;
|
|
352
|
+
try {
|
|
353
|
+
entries = readdirSync(join(from, rel), { withFileTypes: true });
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
for (const e of entries) {
|
|
359
|
+
const r = rel === '' ? e.name : join(rel, e.name);
|
|
360
|
+
if (rel === '' && e.name === 'agent.json')
|
|
361
|
+
continue;
|
|
362
|
+
if (rel === 'resources' && !referenced.has(e.name.toLowerCase()))
|
|
363
|
+
continue;
|
|
364
|
+
if (e.isDirectory()) {
|
|
365
|
+
walk(r);
|
|
366
|
+
}
|
|
367
|
+
else if (e.isFile() && !existsSync(join(to, r))) {
|
|
368
|
+
mkdirSync(dirname(join(to, r)), { recursive: true });
|
|
369
|
+
copyFileSync(join(from, r), join(to, r));
|
|
370
|
+
copied++;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
walk('');
|
|
375
|
+
return copied;
|
|
376
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-embedded-agent — keep an EMBEDDED agent embedded when a brownfield edit
|
|
3
|
+
* touches it (flow-builder-sdk#1220).
|
|
4
|
+
*
|
|
5
|
+
* An agent node stores its configuration (prompts, model, run settings) in one
|
|
6
|
+
* of two places:
|
|
7
|
+
*
|
|
8
|
+
* - embedded: on the node itself (`inputs.systemPrompt`, `inputs.model`, …).
|
|
9
|
+
* Older Studio Web saves, and any save with the self-contained flag on.
|
|
10
|
+
* - shell: the node keeps only its structural keys (`source`,
|
|
11
|
+
* `agentInputVariables`, `agentOutputVariables`, …) and the configuration
|
|
12
|
+
* lives in `<source>/agent.json`.
|
|
13
|
+
*
|
|
14
|
+
* The runtime and Studio Web pick the copy by `hasEmbeddedAgentContent`
|
|
15
|
+
* (`@uipath/flow-schema`): any prompt string on the node makes the node win and
|
|
16
|
+
* the sidecar is never read. Since #927 compile emits only shells. So when an
|
|
17
|
+
* edit changed an embedded agent's node (an input, an output, the label), the
|
|
18
|
+
* old merge took the edited SHELL node whole, and the agent's storage form
|
|
19
|
+
* flipped: the runtime then read the project's `<source>/agent.json`, which an
|
|
20
|
+
* embedded node had been shadowing and which is often stale. On corpus fixture
|
|
21
|
+
* `01-agent-input-binding` that silently changed the model from
|
|
22
|
+
* `gpt-4o-2024-11-20` to `gpt-5.4` and emptied the system prompt.
|
|
23
|
+
*
|
|
24
|
+
* The rule here is the same delta rule merge applies everywhere: the original
|
|
25
|
+
* node, plus what the author changed (`edited` minus `baseline`). The baseline
|
|
26
|
+
* and the edited node are both shells, so the configuration keys never appear in
|
|
27
|
+
* that delta and stay exactly as the original has them. Only the structural
|
|
28
|
+
* keys the author changed are taken from the edit. Without a baseline (a two-way
|
|
29
|
+
* merge), the edited node is taken, and every key the original has that the
|
|
30
|
+
* shell does not (the configuration) is put back.
|
|
31
|
+
*
|
|
32
|
+
* A prompt or model edit in the source does NOT reach an embedded agent: it
|
|
33
|
+
* lands in the edited `<source>/agent.json`, which the embedded node shadows.
|
|
34
|
+
* Merge reports that ({@link embeddedAgentFindings}), and refuses under
|
|
35
|
+
* `strict` any merged node that would no longer carry the original's embedded
|
|
36
|
+
* configuration, or whose kept prompt names an input the node no longer
|
|
37
|
+
* declares.
|
|
38
|
+
*/
|
|
39
|
+
import type { MergeFinding } from './merge-roundtrip.js';
|
|
40
|
+
interface AgentNode {
|
|
41
|
+
id: string;
|
|
42
|
+
type: string;
|
|
43
|
+
inputs?: Record<string, unknown>;
|
|
44
|
+
[k: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether `node` is an agent whose configuration is on the node, by
|
|
48
|
+
* `@uipath/flow-schema`'s `hasEmbeddedAgentContent`: a prompt string, or, on
|
|
49
|
+
* the voice node only, a `voice` settings object.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isEmbeddedAgent(node: {
|
|
52
|
+
type?: string;
|
|
53
|
+
inputs?: Record<string, unknown>;
|
|
54
|
+
} | undefined): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The merged node for an embedded original whose edited node is a shell: the
|
|
57
|
+
* original, plus the author's change to the node.
|
|
58
|
+
*
|
|
59
|
+
* @param orig - The original node (embedded).
|
|
60
|
+
* @param edited - The edited compile's node (a shell).
|
|
61
|
+
* @param base - The baseline compile's node, when the merge has a baseline.
|
|
62
|
+
* @returns The original node with the edit's changed structural inputs and changed top-level keys applied.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mergeEmbeddedAgentNode<N extends AgentNode>(orig: N, edited: N, base: N | undefined): N;
|
|
65
|
+
/**
|
|
66
|
+
* What a merged embedded agent needs said, per original embedded agent (root
|
|
67
|
+
* and subflows, matched by scope and node id):
|
|
68
|
+
*
|
|
69
|
+
* - `agent-storage` (refuses): the merged node no longer carries the original's
|
|
70
|
+
* embedded configuration, so the runtime would read `<source>/agent.json`
|
|
71
|
+
* instead — a model or prompt change the author never wrote.
|
|
72
|
+
* - `agent-prompt-input` (refuses): a kept prompt names `{{input.X}}` but the
|
|
73
|
+
* merged node declares no input `X` (the edit removed or renamed it), so the
|
|
74
|
+
* prompt would render the placeholder empty.
|
|
75
|
+
* - `agent-embedded-edit` (warns): the author edited the node; merge applied the
|
|
76
|
+
* node change and kept the original's prompts, model and settings, so a
|
|
77
|
+
* prompt or model edit in the source did not reach this agent.
|
|
78
|
+
*
|
|
79
|
+
* @param hasBaseline - Whether the merge had a baseline. Without one, merge
|
|
80
|
+
* cannot tell the author's change from the round trip's, so the
|
|
81
|
+
* `agent-embedded-edit` warning says the node may have been edited.
|
|
82
|
+
* @param scopes - Per scope (`''` = root, otherwise the subflow key): the original, merged and edited-node-ids.
|
|
83
|
+
*/
|
|
84
|
+
export declare function embeddedAgentFindings(hasBaseline: boolean, scopes: Array<{
|
|
85
|
+
scope: string;
|
|
86
|
+
original: AgentNode[];
|
|
87
|
+
merged: AgentNode[];
|
|
88
|
+
edited: ReadonlySet<string>;
|
|
89
|
+
}>): MergeFinding[];
|
|
90
|
+
export {};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-embedded-agent — keep an EMBEDDED agent embedded when a brownfield edit
|
|
3
|
+
* touches it (flow-builder-sdk#1220).
|
|
4
|
+
*
|
|
5
|
+
* An agent node stores its configuration (prompts, model, run settings) in one
|
|
6
|
+
* of two places:
|
|
7
|
+
*
|
|
8
|
+
* - embedded: on the node itself (`inputs.systemPrompt`, `inputs.model`, …).
|
|
9
|
+
* Older Studio Web saves, and any save with the self-contained flag on.
|
|
10
|
+
* - shell: the node keeps only its structural keys (`source`,
|
|
11
|
+
* `agentInputVariables`, `agentOutputVariables`, …) and the configuration
|
|
12
|
+
* lives in `<source>/agent.json`.
|
|
13
|
+
*
|
|
14
|
+
* The runtime and Studio Web pick the copy by `hasEmbeddedAgentContent`
|
|
15
|
+
* (`@uipath/flow-schema`): any prompt string on the node makes the node win and
|
|
16
|
+
* the sidecar is never read. Since #927 compile emits only shells. So when an
|
|
17
|
+
* edit changed an embedded agent's node (an input, an output, the label), the
|
|
18
|
+
* old merge took the edited SHELL node whole, and the agent's storage form
|
|
19
|
+
* flipped: the runtime then read the project's `<source>/agent.json`, which an
|
|
20
|
+
* embedded node had been shadowing and which is often stale. On corpus fixture
|
|
21
|
+
* `01-agent-input-binding` that silently changed the model from
|
|
22
|
+
* `gpt-4o-2024-11-20` to `gpt-5.4` and emptied the system prompt.
|
|
23
|
+
*
|
|
24
|
+
* The rule here is the same delta rule merge applies everywhere: the original
|
|
25
|
+
* node, plus what the author changed (`edited` minus `baseline`). The baseline
|
|
26
|
+
* and the edited node are both shells, so the configuration keys never appear in
|
|
27
|
+
* that delta and stay exactly as the original has them. Only the structural
|
|
28
|
+
* keys the author changed are taken from the edit. Without a baseline (a two-way
|
|
29
|
+
* merge), the edited node is taken, and every key the original has that the
|
|
30
|
+
* shell does not (the configuration) is put back.
|
|
31
|
+
*
|
|
32
|
+
* A prompt or model edit in the source does NOT reach an embedded agent: it
|
|
33
|
+
* lands in the edited `<source>/agent.json`, which the embedded node shadows.
|
|
34
|
+
* Merge reports that ({@link embeddedAgentFindings}), and refuses under
|
|
35
|
+
* `strict` any merged node that would no longer carry the original's embedded
|
|
36
|
+
* configuration, or whose kept prompt names an input the node no longer
|
|
37
|
+
* declares.
|
|
38
|
+
*/
|
|
39
|
+
import { jsonClone } from './core/json-clone.js';
|
|
40
|
+
/**
|
|
41
|
+
* The agent node types whose configuration can live on the node or in
|
|
42
|
+
* `<source>/agent.json`, and whether a `voice` settings object on the node
|
|
43
|
+
* counts as embedded configuration (flow-schema counts it on the voice node only).
|
|
44
|
+
*/
|
|
45
|
+
const SIDECAR_AGENT_TYPES = new Map([
|
|
46
|
+
['uipath.agent.autonomous', { voiceSettings: false }],
|
|
47
|
+
['uipath.agent.conversational', { voiceSettings: false }],
|
|
48
|
+
['uipath.agent.voice', { voiceSettings: true }],
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* The agent-node inputs that are flow-level and survive Studio Web's shell
|
|
52
|
+
* strip (`STRUCTURAL_AGENT_INPUT_KEYS`, `@uipath/flow-schema`). Every other
|
|
53
|
+
* input of an embedded node is agent configuration.
|
|
54
|
+
*/
|
|
55
|
+
const STRUCTURAL_AGENT_INPUT_KEYS = new Set([
|
|
56
|
+
'source',
|
|
57
|
+
'agentInputVariables',
|
|
58
|
+
'agentOutputVariables',
|
|
59
|
+
'conversationalAgentSettings',
|
|
60
|
+
'callContext',
|
|
61
|
+
]);
|
|
62
|
+
/** The descriptor arrays, merged entry by entry (keyed by `id`) rather than whole. */
|
|
63
|
+
const VARIABLE_ARRAY_KEYS = new Set(['agentInputVariables', 'agentOutputVariables']);
|
|
64
|
+
/** Node keys the delta never takes from the edit: identity, and the position merge re-attaches separately. */
|
|
65
|
+
const NODE_KEYS_NOT_IN_DELTA = new Set(['id', 'ui', 'inputs']);
|
|
66
|
+
/**
|
|
67
|
+
* Whether `node` is an agent whose configuration is on the node, by
|
|
68
|
+
* `@uipath/flow-schema`'s `hasEmbeddedAgentContent`: a prompt string, or, on
|
|
69
|
+
* the voice node only, a `voice` settings object.
|
|
70
|
+
*/
|
|
71
|
+
export function isEmbeddedAgent(node) {
|
|
72
|
+
const kind = node?.type === undefined ? undefined : SIDECAR_AGENT_TYPES.get(node.type);
|
|
73
|
+
if (!node || kind === undefined)
|
|
74
|
+
return false;
|
|
75
|
+
const i = node.inputs ?? {};
|
|
76
|
+
if (typeof i.systemPrompt === 'string' || typeof i.userPrompt === 'string')
|
|
77
|
+
return true;
|
|
78
|
+
return kind.voiceSettings && typeof i.voice === 'object' && i.voice !== null;
|
|
79
|
+
}
|
|
80
|
+
/** The original's agent-configuration inputs: everything but the structural keys. */
|
|
81
|
+
function configInputs(node) {
|
|
82
|
+
return Object.fromEntries(Object.entries(node.inputs ?? {}).filter(([k]) => !STRUCTURAL_AGENT_INPUT_KEYS.has(k)));
|
|
83
|
+
}
|
|
84
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
85
|
+
function entriesById(v) {
|
|
86
|
+
return new Map((Array.isArray(v) ? v : []).map((e) => [e?.id, e]));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A descriptor array (`agentInputVariables` / `agentOutputVariables`) in the
|
|
90
|
+
* edited order. An entry the author did not change (equal to its baseline
|
|
91
|
+
* entry) is the original's, so it keeps what only the original carries (an
|
|
92
|
+
* embedded entry's `description`, its binding spelling). A changed or added
|
|
93
|
+
* entry is the edited one, over the original entry's extra fields, except a
|
|
94
|
+
* `description` when the edit rebound the entry: the original's describes the
|
|
95
|
+
* old binding (for instance "Bound from $vars.prep1.output.ticket").
|
|
96
|
+
*/
|
|
97
|
+
function mergeVariableArray(orig, edited, base, hasBaseline) {
|
|
98
|
+
if (!Array.isArray(edited))
|
|
99
|
+
return jsonClone(edited);
|
|
100
|
+
const o = entriesById(orig);
|
|
101
|
+
const b = entriesById(base);
|
|
102
|
+
return edited.map((e) => {
|
|
103
|
+
const oe = o.get(e?.id);
|
|
104
|
+
if (oe === undefined)
|
|
105
|
+
return jsonClone(e);
|
|
106
|
+
const be = b.get(e?.id);
|
|
107
|
+
if (hasBaseline && be !== undefined && same(be, e))
|
|
108
|
+
return jsonClone(oe);
|
|
109
|
+
// Two-way: an entry that matches the original's apart from the binding's
|
|
110
|
+
// spelling and the fields compile does not write is the original's.
|
|
111
|
+
if (!hasBaseline && Object.keys(e ?? {}).every((k) => (k === 'binding'
|
|
112
|
+
? bindingText(oe.binding) === bindingText(e.binding)
|
|
113
|
+
: same(oe[k], e[k]))))
|
|
114
|
+
return jsonClone(oe);
|
|
115
|
+
const merged = { ...jsonClone(oe), ...jsonClone(e) };
|
|
116
|
+
// The baseline and the edit spell bindings the same way (both compiled); the
|
|
117
|
+
// original may not (`=$vars.x` vs `=js:$vars.x`), so compare it by text.
|
|
118
|
+
const rebound = hasBaseline && be !== undefined
|
|
119
|
+
? !same(be.binding, e.binding)
|
|
120
|
+
: bindingText(oe.binding) !== bindingText(e.binding);
|
|
121
|
+
if (rebound && !('description' in e))
|
|
122
|
+
delete merged.description;
|
|
123
|
+
return merged;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** A binding's expression text, without the `=` / `=js:` prefix, so two spellings of one binding compare equal. */
|
|
127
|
+
function bindingText(binding) {
|
|
128
|
+
return typeof binding === 'string' ? binding.replace(/^=(js:)?/, '') : binding;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The merged node for an embedded original whose edited node is a shell: the
|
|
132
|
+
* original, plus the author's change to the node.
|
|
133
|
+
*
|
|
134
|
+
* @param orig - The original node (embedded).
|
|
135
|
+
* @param edited - The edited compile's node (a shell).
|
|
136
|
+
* @param base - The baseline compile's node, when the merge has a baseline.
|
|
137
|
+
* @returns The original node with the edit's changed structural inputs and changed top-level keys applied.
|
|
138
|
+
*/
|
|
139
|
+
export function mergeEmbeddedAgentNode(orig, edited, base) {
|
|
140
|
+
if (base === undefined) {
|
|
141
|
+
// Two-way: the edited node, with the original's configuration put back.
|
|
142
|
+
const out = jsonClone(edited);
|
|
143
|
+
const inputs = { ...(out.inputs ?? {}) };
|
|
144
|
+
for (const k of VARIABLE_ARRAY_KEYS) {
|
|
145
|
+
if (k in inputs)
|
|
146
|
+
inputs[k] = mergeVariableArray(orig.inputs?.[k], inputs[k], undefined, false);
|
|
147
|
+
}
|
|
148
|
+
out.inputs = { ...inputs, ...jsonClone(configInputs(orig)) };
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
const out = jsonClone(orig);
|
|
152
|
+
// Top-level keys (outputs, display, typeVersion, parentId, …): the edit's
|
|
153
|
+
// value where the author changed it.
|
|
154
|
+
for (const k of new Set([...Object.keys(base), ...Object.keys(edited)])) {
|
|
155
|
+
if (NODE_KEYS_NOT_IN_DELTA.has(k) || same(base[k], edited[k]))
|
|
156
|
+
continue;
|
|
157
|
+
if (k in edited)
|
|
158
|
+
out[k] = jsonClone(edited[k]);
|
|
159
|
+
else
|
|
160
|
+
delete out[k];
|
|
161
|
+
}
|
|
162
|
+
// Inputs: the same, key by key. The configuration keys are on neither shell,
|
|
163
|
+
// so they never differ here and stay the original's.
|
|
164
|
+
const bi = base.inputs ?? {};
|
|
165
|
+
const ei = edited.inputs ?? {};
|
|
166
|
+
const inputs = { ...(out.inputs ?? {}) };
|
|
167
|
+
for (const k of new Set([...Object.keys(bi), ...Object.keys(ei)])) {
|
|
168
|
+
if (same(bi[k], ei[k]))
|
|
169
|
+
continue;
|
|
170
|
+
if (!(k in ei))
|
|
171
|
+
delete inputs[k];
|
|
172
|
+
else if (VARIABLE_ARRAY_KEYS.has(k))
|
|
173
|
+
inputs[k] = mergeVariableArray(orig.inputs?.[k], ei[k], bi[k], true);
|
|
174
|
+
else
|
|
175
|
+
inputs[k] = jsonClone(ei[k]);
|
|
176
|
+
}
|
|
177
|
+
out.inputs = inputs;
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/** `{{input.<name>}}` placeholders in a prompt string. */
|
|
181
|
+
function promptInputs(prompt) {
|
|
182
|
+
if (typeof prompt !== 'string')
|
|
183
|
+
return [];
|
|
184
|
+
return [...prompt.matchAll(/\{\{\s*input\.([A-Za-z_$][\w$]*)\s*\}\}/g)].map((m) => m[1]);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* What a merged embedded agent needs said, per original embedded agent (root
|
|
188
|
+
* and subflows, matched by scope and node id):
|
|
189
|
+
*
|
|
190
|
+
* - `agent-storage` (refuses): the merged node no longer carries the original's
|
|
191
|
+
* embedded configuration, so the runtime would read `<source>/agent.json`
|
|
192
|
+
* instead — a model or prompt change the author never wrote.
|
|
193
|
+
* - `agent-prompt-input` (refuses): a kept prompt names `{{input.X}}` but the
|
|
194
|
+
* merged node declares no input `X` (the edit removed or renamed it), so the
|
|
195
|
+
* prompt would render the placeholder empty.
|
|
196
|
+
* - `agent-embedded-edit` (warns): the author edited the node; merge applied the
|
|
197
|
+
* node change and kept the original's prompts, model and settings, so a
|
|
198
|
+
* prompt or model edit in the source did not reach this agent.
|
|
199
|
+
*
|
|
200
|
+
* @param hasBaseline - Whether the merge had a baseline. Without one, merge
|
|
201
|
+
* cannot tell the author's change from the round trip's, so the
|
|
202
|
+
* `agent-embedded-edit` warning says the node may have been edited.
|
|
203
|
+
* @param scopes - Per scope (`''` = root, otherwise the subflow key): the original, merged and edited-node-ids.
|
|
204
|
+
*/
|
|
205
|
+
export function embeddedAgentFindings(hasBaseline, scopes) {
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const { scope, original, merged, edited } of scopes) {
|
|
208
|
+
const where = scope === '' ? '' : ` in subflow "${scope}"`;
|
|
209
|
+
const mergedById = new Map(merged.map((n) => [n.id, n]));
|
|
210
|
+
for (const o of original) {
|
|
211
|
+
if (!isEmbeddedAgent(o))
|
|
212
|
+
continue;
|
|
213
|
+
const m = mergedById.get(o.id);
|
|
214
|
+
if (m === undefined || m.type !== o.type)
|
|
215
|
+
continue; // deleted or replaced by the author
|
|
216
|
+
const lost = Object.entries(configInputs(o))
|
|
217
|
+
.filter(([k, v]) => !same(m.inputs?.[k], v))
|
|
218
|
+
.map(([k]) => k);
|
|
219
|
+
if (!isEmbeddedAgent(m) || lost.length > 0) {
|
|
220
|
+
out.push({
|
|
221
|
+
kind: 'agent-storage', refuses: true,
|
|
222
|
+
message: `agent "${o.id}"${where} carries its configuration on the node (embedded), but the merged node `
|
|
223
|
+
+ `${isEmbeddedAgent(m) ? 'changes' : 'drops'} ${lost.map((k) => `inputs.${k}`).join(', ') || 'its prompts'}, `
|
|
224
|
+
+ 'so the agent would run a configuration the author did not write (flow-builder-sdk#1220).',
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const declared = new Set((Array.isArray(m.inputs?.agentInputVariables) ? m.inputs.agentInputVariables : [])
|
|
229
|
+
.map((v) => v?.id)
|
|
230
|
+
.filter((id) => typeof id === 'string'));
|
|
231
|
+
const missing = [...new Set([...promptInputs(m.inputs?.systemPrompt), ...promptInputs(m.inputs?.userPrompt)])]
|
|
232
|
+
.filter((name) => !declared.has(name));
|
|
233
|
+
const origDeclared = new Set((Array.isArray(o.inputs?.agentInputVariables) ? o.inputs.agentInputVariables : []).map((v) => v?.id));
|
|
234
|
+
// A placeholder the original already left undeclared is not the merge's doing.
|
|
235
|
+
const newlyMissing = missing.filter((name) => origDeclared.has(name));
|
|
236
|
+
if (newlyMissing.length > 0) {
|
|
237
|
+
out.push({
|
|
238
|
+
kind: 'agent-prompt-input', refuses: true,
|
|
239
|
+
message: `agent "${o.id}"${where} keeps its original embedded prompt, which uses `
|
|
240
|
+
+ `${newlyMissing.map((n) => `{{input.${n}}}`).join(', ')}, but the edit removed or renamed that input. `
|
|
241
|
+
+ 'An embedded agent\'s prompts are not taken from the source; change the prompt on the canvas too.',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (edited.has(o.id)) {
|
|
245
|
+
out.push({
|
|
246
|
+
kind: 'agent-embedded-edit', refuses: false,
|
|
247
|
+
message: `agent "${o.id}"${where} carries its prompts, model and settings on the node (embedded). Merge applied `
|
|
248
|
+
+ (hasBaseline
|
|
249
|
+
? 'your change to the node'
|
|
250
|
+
: 'the edited node\'s inputs and outputs (with no baseline it cannot tell your change from the round trip\'s, '
|
|
251
|
+
+ 'so this node may not have been edited)')
|
|
252
|
+
+ ' and kept those as the original has them. A prompt, model or settings edit in the '
|
|
253
|
+
+ 'source is not carried for an embedded agent (it would land in <source>/agent.json, which the node '
|
|
254
|
+
+ 'shadows); make that change on the canvas.',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
@@ -33,12 +33,13 @@ interface RtFlow {
|
|
|
33
33
|
/** One difference, or one inconsistency in the merged file. */
|
|
34
34
|
export interface MergeFinding {
|
|
35
35
|
/** Which comparison found it. */
|
|
36
|
-
kind: 'node-removed' | 'node-added' | 'node-type' | 'implicit-end' | 'edge-handle-renamed' | 'edge-rewired' | 'edge-removed' | 'edge-added' | 'handle-ids' | 'binding-removed' | 'binding-added' | 'undeclared-handle' | 'duplicate-binding' | 'agent-sidecar';
|
|
36
|
+
kind: 'node-removed' | 'node-added' | 'node-type' | 'implicit-end' | 'edge-handle-renamed' | 'edge-rewired' | 'edge-removed' | 'edge-added' | 'handle-ids' | 'binding-removed' | 'binding-added' | 'undeclared-handle' | 'duplicate-binding' | 'agent-sidecar' | 'agent-storage' | 'agent-prompt-input' | 'agent-embedded-edit';
|
|
37
37
|
/** The finding, phrased for the author, naming the node, edge or row. */
|
|
38
38
|
message: string;
|
|
39
39
|
/**
|
|
40
40
|
* Whether `strict` refuses the merge over it. False for `implicit-end` and
|
|
41
|
-
* `binding-removed`, which leave the merged file doing what the original does
|
|
41
|
+
* `binding-removed`, which leave the merged file doing what the original does,
|
|
42
|
+
* and for `agent-embedded-edit`, which reports what merge did not carry.
|
|
42
43
|
*/
|
|
43
44
|
refuses: boolean;
|
|
44
45
|
}
|
package/dist/merge-sidecars.js
CHANGED
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
* Pure: the caller reads the files ({@link SidecarSet}) and writes the result.
|
|
66
66
|
*/
|
|
67
67
|
import { jsonClone } from './core/json-clone.js';
|
|
68
|
+
import { isEmbeddedAgent } from './merge-embedded-agent.js';
|
|
68
69
|
/** The `{ path, json }` list `serializeWithSidecars` returns, as a {@link SidecarSet}. */
|
|
69
70
|
export function sidecarSetFromList(list) {
|
|
70
71
|
return (source) => {
|
|
@@ -258,11 +259,13 @@ function carryResources(source, files, flows) {
|
|
|
258
259
|
}
|
|
259
260
|
return { writes, removals, unmatched };
|
|
260
261
|
}
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
/**
|
|
263
|
+
* The `agent.json` keys an embedded node carries itself, in its descriptor
|
|
264
|
+
* arrays (`agentInputVariables` / `agentOutputVariables`). An input or output
|
|
265
|
+
* edit changes them in the compiled `agent.json` and on the node; merge applies
|
|
266
|
+
* the node change (#1220), so their delta is not an edit the node shadows.
|
|
267
|
+
*/
|
|
268
|
+
const SCHEMA_ON_THE_NODE = /^(inputSchema|outputSchema)(\.|$)/;
|
|
266
269
|
const finding = (message) => ({ kind: 'agent-sidecar', message, refuses: true });
|
|
267
270
|
/**
|
|
268
271
|
* The sidecar files the merged flow needs written beside it, and what merge could
|
|
@@ -310,12 +313,20 @@ export function mergeAgentSidecars(merged, sources, flows) {
|
|
|
310
313
|
const changed = [];
|
|
311
314
|
const next = mergeJson3(orig[agentPath], base[agentPath], edited[agentPath], changed);
|
|
312
315
|
if (changed.length > 0) {
|
|
313
|
-
// `node` is the merged node: the original's when merge kept it
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
// `node` is the merged node: the original's when merge kept it; when the
|
|
317
|
+
// author changed the node too, the edited shell, or, for an embedded
|
|
318
|
+
// original, the original plus that change (#1220), still embedded.
|
|
319
|
+
// An embedded node (prompts or voice settings on the node) is read before
|
|
320
|
+
// agent.json, so an agent.json edit does not reach it. Its input and
|
|
321
|
+
// output schemas are the exception: the node's descriptor arrays carry
|
|
322
|
+
// them, and merge applied that node change already.
|
|
323
|
+
if (isEmbeddedAgent(node)) {
|
|
324
|
+
const shadowed = changed.filter((p) => !SCHEMA_ON_THE_NODE.test(p));
|
|
325
|
+
if (shadowed.length > 0) {
|
|
326
|
+
findings.push(finding(`agent "${node.id}": its ${agentPath} was edited (${shadowed.join(', ')}), but the original node carries its `
|
|
327
|
+
+ 'own configuration (embedded), which is read before agent.json, so the edit would not reach the agent. The '
|
|
328
|
+
+ 'change is NOT carried. Make the change on the canvas, or save the agent with its prompts in agent.json only.'));
|
|
329
|
+
}
|
|
319
330
|
}
|
|
320
331
|
else if (stable(next) !== stable(orig[agentPath])) {
|
|
321
332
|
writes.push({ path: agentPath, json: next, carried: changed });
|
package/dist/merge.d.ts
CHANGED
|
@@ -75,6 +75,12 @@
|
|
|
75
75
|
* `warn`, and `strict` refuses the merge over it; the merged file is also
|
|
76
76
|
* checked for an edge that leaves a handle its kept source node does not
|
|
77
77
|
* declare. See merge-roundtrip.ts.
|
|
78
|
+
*
|
|
79
|
+
* ## An embedded agent stays embedded
|
|
80
|
+
*
|
|
81
|
+
* An agent node whose original carries its prompts and model on the node is
|
|
82
|
+
* never replaced by the edited compile's shell node: the merged node is the
|
|
83
|
+
* original plus the author's change to the node. See merge-embedded-agent.ts.
|
|
78
84
|
*/
|
|
79
85
|
import { type MergeSidecarSources, type SidecarWrite } from './merge-sidecars.js';
|
|
80
86
|
export { MergeRefusedError, type MergeFinding } from './merge-roundtrip.js';
|
|
@@ -142,7 +148,12 @@ export interface MergeOptions {
|
|
|
142
148
|
* - an edited node whose `typeVersion` differs from the original node with
|
|
143
149
|
* the same id, because the edit took the SDK's version;
|
|
144
150
|
* - a merged edge whose `sourcePort` is not a handle its source node declares,
|
|
145
|
-
* and two merged binding rows for the same resource property
|
|
151
|
+
* and two merged binding rows for the same resource property;
|
|
152
|
+
* - an edited agent whose original carries its prompts and model on the node
|
|
153
|
+
* (embedded): merge keeps them and applies only the node change, so a prompt
|
|
154
|
+
* or model edit in the source is not carried. Under `strict` it refuses a
|
|
155
|
+
* merged node that would drop or change that embedded configuration, or
|
|
156
|
+
* whose kept prompt uses an input the edit removed (flow-builder-sdk#1220).
|
|
146
157
|
*/
|
|
147
158
|
warn?: (message: string) => void;
|
|
148
159
|
/**
|
package/dist/merge.js
CHANGED
|
@@ -75,6 +75,12 @@
|
|
|
75
75
|
* `warn`, and `strict` refuses the merge over it; the merged file is also
|
|
76
76
|
* checked for an edge that leaves a handle its kept source node does not
|
|
77
77
|
* declare. See merge-roundtrip.ts.
|
|
78
|
+
*
|
|
79
|
+
* ## An embedded agent stays embedded
|
|
80
|
+
*
|
|
81
|
+
* An agent node whose original carries its prompts and model on the node is
|
|
82
|
+
* never replaced by the edited compile's shell node: the merged node is the
|
|
83
|
+
* original plus the author's change to the node. See merge-embedded-agent.ts.
|
|
78
84
|
*/
|
|
79
85
|
import { bindingIdentity } from './core/binding-identity.js';
|
|
80
86
|
import { jsonClone } from './core/json-clone.js';
|
|
@@ -82,6 +88,7 @@ import { DEFINITIONLESS_NODE_TYPES } from './core/definitionless-node-types.js';
|
|
|
82
88
|
import { MergeRefusedError, mergedFileFindings, roundTripFindings } from './merge-roundtrip.js';
|
|
83
89
|
import { placeAddedNodes } from './node-placement.js';
|
|
84
90
|
import { mergeAgentSidecars } from './merge-sidecars.js';
|
|
91
|
+
import { embeddedAgentFindings, isEmbeddedAgent, mergeEmbeddedAgentNode } from './merge-embedded-agent.js';
|
|
85
92
|
export { MergeRefusedError } from './merge-roundtrip.js';
|
|
86
93
|
function byId(nodes) {
|
|
87
94
|
return new Map((nodes ?? []).map((n) => [n.id, n]));
|
|
@@ -218,6 +225,8 @@ function mergeFlowFindings(original, edited, options) {
|
|
|
218
225
|
// references are the original's now, not the recompile's
|
|
219
226
|
// (see withoutRowsMintedForKeptNodes).
|
|
220
227
|
const keptFromOriginal = new Set();
|
|
228
|
+
/** Root node ids taken from `edited` that the original also has (the author edited them). */
|
|
229
|
+
const editedIds = new Set();
|
|
221
230
|
for (const e of edited.nodes ?? []) {
|
|
222
231
|
const orig = origNodes.get(e.id);
|
|
223
232
|
if (isKept(e, origNodes, baseNodes, options.baseline) && orig) {
|
|
@@ -228,9 +237,17 @@ function mergeFlowFindings(original, edited, options) {
|
|
|
228
237
|
}
|
|
229
238
|
else {
|
|
230
239
|
// Added or edited → take the edited node, re-attaching original layout.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
// An EMBEDDED agent stays embedded: the original plus the author's change
|
|
241
|
+
// to the node, never the edited shell whole (#1220, merge-embedded-agent.ts).
|
|
242
|
+
const node = keepsEmbeddedAgent(orig, e) ? mergeEmbeddedAgentNode(orig, e, baseNodes.get(e.id)) : jsonClone(e);
|
|
243
|
+
const placed = withOriginalSource(withOriginalLayout(node, orig, layoutNodesOf(original)), orig, baseNodes.get(e.id));
|
|
244
|
+
finalNodes.push(placed);
|
|
245
|
+
// Without a baseline every node lands here; count one as edited only
|
|
246
|
+
// when the merged node differs from the original.
|
|
247
|
+
if (orig && (options.baseline || !sameApartFromUi(placed, orig)))
|
|
248
|
+
editedIds.add(e.id);
|
|
249
|
+
if (orig && orig.type === node.type && orig.typeVersion !== node.typeVersion) {
|
|
250
|
+
options.warn?.(`node ${JSON.stringify(e.id)} moved ${e.type}@${String(orig.typeVersion)} -> ${String(node.typeVersion)} `
|
|
234
251
|
+ `(the edited source does not pin ${String(orig.typeVersion)}; keep the { version } pin, `
|
|
235
252
|
+ `with .definitions([...]) when the SDK does not bundle that version).`);
|
|
236
253
|
}
|
|
@@ -242,6 +259,11 @@ function mergeFlowFindings(original, edited, options) {
|
|
|
242
259
|
// ── Variables & subflows: the edited flow is authoritative (the user's graph). ──
|
|
243
260
|
result.variables = jsonClone(edited.variables);
|
|
244
261
|
result.subflows = jsonClone(edited.subflows ?? {});
|
|
262
|
+
// A subflow is taken from `edited` whole, so an embedded agent inside one
|
|
263
|
+
// would flip to the edited shell even with no edit. Keep it embedded too.
|
|
264
|
+
// This runs before the source fix-up below: it compares each node with the
|
|
265
|
+
// baseline to tell which ones the author edited.
|
|
266
|
+
const subflowEdited = keepEmbeddedAgentsInSubflows(original, result.subflows, options.baseline);
|
|
245
267
|
if (options.baseline)
|
|
246
268
|
keepOriginalSourcesInSubflows(original, result.subflows, options.baseline);
|
|
247
269
|
const subflowScopes = Object.values(result.subflows);
|
|
@@ -296,9 +318,74 @@ function mergeFlowFindings(original, edited, options) {
|
|
|
296
318
|
const findings = [
|
|
297
319
|
...(options.baseline ? roundTripFindings(original, options.baseline, result) : []),
|
|
298
320
|
...mergedFileFindings(result, original),
|
|
321
|
+
...embeddedAgentFindings(!!options.baseline, [
|
|
322
|
+
{ scope: '', original: original.nodes ?? [], merged: finalNodes, edited: editedIds },
|
|
323
|
+
...Object.entries(original.subflows ?? {}).map(([key, sf]) => ({
|
|
324
|
+
scope: key,
|
|
325
|
+
original: sf?.nodes ?? [],
|
|
326
|
+
merged: result.subflows?.[key]?.nodes ?? [],
|
|
327
|
+
edited: subflowEdited.get(key) ?? new Set(),
|
|
328
|
+
})),
|
|
329
|
+
]),
|
|
299
330
|
];
|
|
300
331
|
return { result, findings };
|
|
301
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Whether the merged node for `orig` must be built from the original rather than
|
|
335
|
+
* taken from `edited`: the original is an embedded agent and the edited node is
|
|
336
|
+
* not (compile emits only shells), so taking it would move the agent's
|
|
337
|
+
* configuration from the node to `<source>/agent.json`.
|
|
338
|
+
*/
|
|
339
|
+
function keepsEmbeddedAgent(orig, e) {
|
|
340
|
+
return orig !== undefined && orig.type === e.type && isEmbeddedAgent(orig) && !isEmbeddedAgent(e);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Whether two nodes are equal apart from their position (`ui`), which merge
|
|
344
|
+
* re-attaches separately, and apart from object key order (a two-way embedded
|
|
345
|
+
* merge puts the original's configuration keys after the edited shell's).
|
|
346
|
+
*/
|
|
347
|
+
function sameApartFromUi(a, b) {
|
|
348
|
+
if (!a || !b)
|
|
349
|
+
return false;
|
|
350
|
+
const { ui: _a, ...ra } = a;
|
|
351
|
+
const { ui: _b, ...rb } = b;
|
|
352
|
+
return sortedJson(ra) === sortedJson(rb);
|
|
353
|
+
}
|
|
354
|
+
/** JSON text with object keys sorted, so two objects that differ only in key order compare equal. */
|
|
355
|
+
function sortedJson(v) {
|
|
356
|
+
return JSON.stringify(v, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x)
|
|
357
|
+
? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
358
|
+
: x));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* In each subflow taken from `edited`, replace an edited shell agent whose
|
|
362
|
+
* original (same subflow, same id) is embedded with
|
|
363
|
+
* {@link mergeEmbeddedAgentNode}'s result. Returns, per subflow, the ids of the
|
|
364
|
+
* original's nodes the author edited (differ from the baseline, or, without a
|
|
365
|
+
* baseline, merged differently from the original).
|
|
366
|
+
*/
|
|
367
|
+
function keepEmbeddedAgentsInSubflows(original, subflows, baseline) {
|
|
368
|
+
const edited = new Map();
|
|
369
|
+
for (const [key, sf] of Object.entries(subflows)) {
|
|
370
|
+
const nodes = sf?.nodes;
|
|
371
|
+
if (!Array.isArray(nodes))
|
|
372
|
+
continue;
|
|
373
|
+
const origNodes = byId(original.subflows?.[key]?.nodes);
|
|
374
|
+
const baseNodes = byId(baseline?.subflows?.[key]?.nodes);
|
|
375
|
+
const ids = new Set();
|
|
376
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
377
|
+
const e = nodes[i];
|
|
378
|
+
const orig = origNodes.get(e.id);
|
|
379
|
+
if (keepsEmbeddedAgent(orig, e))
|
|
380
|
+
nodes[i] = mergeEmbeddedAgentNode(orig, e, baseline ? baseNodes.get(e.id) : undefined);
|
|
381
|
+
const changed = baseline ? !nodeUnchanged(e, baseNodes.get(e.id)) : !sameApartFromUi(nodes[i], orig);
|
|
382
|
+
if (orig && changed)
|
|
383
|
+
ids.add(e.id);
|
|
384
|
+
}
|
|
385
|
+
edited.set(key, ids);
|
|
386
|
+
}
|
|
387
|
+
return edited;
|
|
388
|
+
}
|
|
302
389
|
/**
|
|
303
390
|
* The root edges with their edited ids, except that an edge with no id, or with
|
|
304
391
|
* an id already used by a subflow edge or an earlier root edge, gets a fresh
|
|
@@ -7,3 +7,11 @@
|
|
|
7
7
|
* nothing.
|
|
8
8
|
*/
|
|
9
9
|
export declare function resourceSourcesOfOtherFlows(dir: string, self: string): Map<string, string>;
|
|
10
|
+
/**
|
|
11
|
+
* Every `inputs.source` string a flow's nodes name — agent folders and agent
|
|
12
|
+
* resource ids alike — root scope and subflow bodies, in the case the file
|
|
13
|
+
* spells them. A malformed flow yields nothing. The one walk both
|
|
14
|
+
* {@link resourceSourcesOfOtherFlows} and compile's own-flow checks use, so the
|
|
15
|
+
* two cannot disagree on where a node keeps its source.
|
|
16
|
+
*/
|
|
17
|
+
export declare function flowSources(flow: unknown): string[];
|
|
@@ -31,17 +31,31 @@ export function resourceSourcesOfOtherFlows(dir, self) {
|
|
|
31
31
|
catch {
|
|
32
32
|
continue;
|
|
33
33
|
}
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
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);
|
|
34
|
+
for (const src of flowSources(flow))
|
|
35
|
+
if (!users.has(src))
|
|
36
|
+
users.set(src, f);
|
|
45
37
|
}
|
|
46
38
|
return users;
|
|
47
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Every `inputs.source` string a flow's nodes name — agent folders and agent
|
|
42
|
+
* resource ids alike — root scope and subflow bodies, in the case the file
|
|
43
|
+
* spells them. A malformed flow yields nothing. The one walk both
|
|
44
|
+
* {@link resourceSourcesOfOtherFlows} and compile's own-flow checks use, so the
|
|
45
|
+
* two cannot disagree on where a node keeps its source.
|
|
46
|
+
*/
|
|
47
|
+
export function flowSources(flow) {
|
|
48
|
+
const ids = [];
|
|
49
|
+
const walk = (scope) => {
|
|
50
|
+
const s = scope;
|
|
51
|
+
for (const n of Array.isArray(s?.nodes) ? s.nodes : []) {
|
|
52
|
+
const src = n?.inputs?.source;
|
|
53
|
+
if (typeof src === 'string')
|
|
54
|
+
ids.push(src);
|
|
55
|
+
}
|
|
56
|
+
if (s?.subflows && typeof s.subflows === 'object')
|
|
57
|
+
Object.values(s.subflows).forEach(walk);
|
|
58
|
+
};
|
|
59
|
+
walk(flow);
|
|
60
|
+
return ids;
|
|
61
|
+
}
|
package/dist/serialize.d.ts
CHANGED
|
@@ -99,6 +99,18 @@ export interface SerializeOpts {
|
|
|
99
99
|
path: string;
|
|
100
100
|
json: unknown;
|
|
101
101
|
}>;
|
|
102
|
+
/**
|
|
103
|
+
* Maps an agent's folder uuid (its `source`) to the one this compile writes,
|
|
104
|
+
* given the step's name. `compile-cli` passes it when the `source` a step
|
|
105
|
+
* carries already belongs to ANOTHER `.flow` in the same Flow project (#1184):
|
|
106
|
+
* decompile keeps a canvas agent's `source`, so compiling that source to a
|
|
107
|
+
* second `.flow` would otherwise write into the first flow's folder. The uuid
|
|
108
|
+
* returned is spent everywhere the `source` is: the node's `inputs.source`,
|
|
109
|
+
* the `agent.json` identity, and every sidecar path.
|
|
110
|
+
*
|
|
111
|
+
* @internal
|
|
112
|
+
*/
|
|
113
|
+
agentSource?: (source: string, step: string) => string;
|
|
102
114
|
}
|
|
103
115
|
/**
|
|
104
116
|
* `serialize`, plus the extra files the flow needs on disk beside it.
|
package/dist/serialize.js
CHANGED
|
@@ -4335,6 +4335,17 @@ export function serialize(built, opts = {}) {
|
|
|
4335
4335
|
* gets them and one that does not is unaffected.
|
|
4336
4336
|
*/
|
|
4337
4337
|
const sidecars = opts.sidecars ?? [];
|
|
4338
|
+
/**
|
|
4339
|
+
* An agent step's folder uuid (`source`): the one the step carries, else one
|
|
4340
|
+
* derived from the flow id + step name rather than random, so a recompile is
|
|
4341
|
+
* byte-identical — a v4-shaped uuid over a stable hash, because the platform
|
|
4342
|
+
* treats it as an opaque id and the designer shows it as a folder. Then
|
|
4343
|
+
* `opts.agentSource` may move it to a folder this flow owns (#1184).
|
|
4344
|
+
*/
|
|
4345
|
+
const agentFolder = (carried, stepName) => {
|
|
4346
|
+
const source = carried ?? fnv1aX4Uuid(`${built.id}/${stepName}`);
|
|
4347
|
+
return opts.agentSource ? opts.agentSource(source, stepName) : source;
|
|
4348
|
+
};
|
|
4338
4349
|
const subflows = {};
|
|
4339
4350
|
const rootGlobals = emittableGlobals(built, `flow "${built.id}"`);
|
|
4340
4351
|
const root = newScope(rootGlobals.outputs, rootGlobals.vars, rootGlobals.backing, rootGlobals.backingReads);
|
|
@@ -5146,11 +5157,8 @@ export function serialize(built, opts = {}) {
|
|
|
5146
5157
|
: `Drop { version: '${version}' } to use ${INLINE_AGENT_DEFAULT_VERSION}, or drop \`memory\`.`));
|
|
5147
5158
|
}
|
|
5148
5159
|
const id = stepUid(scope, step.name);
|
|
5149
|
-
// `source` is the agent's directory name
|
|
5150
|
-
|
|
5151
|
-
// over a stable hash, because the platform treats it as an opaque id and the
|
|
5152
|
-
// designer shows it as a folder.
|
|
5153
|
-
const source = s.source ?? fnv1aX4Uuid(`${built.id}/${step.name}`);
|
|
5160
|
+
// `source` is the agent's directory name; see `agentFolder`.
|
|
5161
|
+
const source = agentFolder(s.source, step.name);
|
|
5154
5162
|
const node = makeNode(def.nodeType, id, step.name, { parentId, requestedVersion: step.options?.version, def });
|
|
5155
5163
|
// Unlike the published families the inline agent KEEPS no node-level model:
|
|
5156
5164
|
// 29 of 29 deployed instances carry none (the def's `model.source: true` is
|
|
@@ -5706,7 +5714,7 @@ export function serialize(built, opts = {}) {
|
|
|
5706
5714
|
// lets the platform do that translation.
|
|
5707
5715
|
const s = spec.inputs;
|
|
5708
5716
|
const id = stepUid(scope, step.name);
|
|
5709
|
-
const source = s.source
|
|
5717
|
+
const source = agentFolder(s.source, step.name);
|
|
5710
5718
|
const node = makeNode(NODE_TYPE.conversationalAgent, id, step.name, { parentId, requestedVersion: step.options?.version });
|
|
5711
5719
|
delete node.model;
|
|
5712
5720
|
const st = s.settings ?? {};
|
|
@@ -5799,7 +5807,7 @@ export function serialize(built, opts = {}) {
|
|
|
5799
5807
|
// definition declares what comes back.
|
|
5800
5808
|
const s = spec.inputs;
|
|
5801
5809
|
const id = stepUid(scope, step.name);
|
|
5802
|
-
const source = s.source
|
|
5810
|
+
const source = agentFolder(s.source, step.name);
|
|
5803
5811
|
const node = makeNode(NODE_TYPE.voiceAgent, id, step.name, { parentId, requestedVersion: step.options?.version });
|
|
5804
5812
|
// Same rule as the inline agent's: the resource model lives on the
|
|
5805
5813
|
// definition (its `model.source: true` is what makes `source` an input).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uipath/maestro-builder-sdk",
|
|
3
|
-
"version": "6.31.
|
|
3
|
+
"version": "6.31.7",
|
|
4
4
|
"description": "Build UiPath Flow, Case, and BPMN artifacts by writing TypeScript.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://docs.uipath.com/maestro",
|
|
@@ -93,5 +93,5 @@
|
|
|
93
93
|
"@types/node": "^22.7.0",
|
|
94
94
|
"esbuild": "^0.28.1"
|
|
95
95
|
},
|
|
96
|
-
"gitref": "
|
|
96
|
+
"gitref": "99f1861467ac06fba1c816f365bf027e1e2c8e05"
|
|
97
97
|
}
|