@uipath/maestro-builder-sdk 6.29.2 → 6.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-index.md +1 -1
- package/dist/api-members.md +6 -5
- package/dist/check.js +9 -2
- package/dist/core/hitl-routing.d.ts +17 -3
- package/dist/core/hitl-routing.js +11 -4
- package/dist/decompile-cli.js +11 -3
- package/dist/flow-node-type.d.ts +6 -0
- package/dist/merge-cli.d.ts +7 -0
- package/dist/merge-cli.js +95 -9
- package/dist/merge-compile.d.ts +8 -1
- package/dist/merge-compile.js +3 -2
- package/dist/merge-roundtrip.d.ts +1 -1
- package/dist/merge-roundtrip.js +2 -1
- package/dist/merge-sidecars.d.ts +71 -0
- package/dist/merge-sidecars.js +214 -0
- package/dist/merge.d.ts +17 -2
- package/dist/merge.js +31 -9
- package/dist/nodes/hitl/flow/check.js +24 -0
- package/dist/nodes/hitl/flow/node-type.js +36 -19
- package/dist/nodes/hitl/flow/spec.d.ts +33 -0
- package/dist/serialize.js +18 -6
- package/package.json +2 -2
package/dist/api-index.md
CHANGED
|
@@ -150,7 +150,7 @@ could hand you spans for a version you do not have.
|
|
|
150
150
|
| `HitlAssigneeType` | type | `dist/nodes/hitl/flow/spec.d.ts:16-23` | Supporting types | How the task is assigned, the platform's own vocabulary (canvas `HITL_ASSIGNEE_TYPE`): `'user'` a single user, `'group'`… |
|
|
151
151
|
| `HitlChannel` | type | `dist/nodes/hitl/flow/spec.d.ts:10-15` | Supporting types | Where the task is delivered. |
|
|
152
152
|
| `HitlConnection` | interface | `dist/nodes/hitl/flow/spec.d.ts:24-32` | Supporting types | A per-channel Integration Service connection (Slack / Teams delivery). |
|
|
153
|
-
| `HitlInputs` | interface | `dist/nodes/hitl/flow/spec.d.ts:186-
|
|
153
|
+
| `HitlInputs` | interface | `dist/nodes/hitl/flow/spec.d.ts:186-290` | Option shapes | 12 field(s) |
|
|
154
154
|
| `HitlRecipient` | interface | `dist/nodes/hitl/flow/spec.d.ts:33-69` | Supporting types | Who gets the task and over which channels. |
|
|
155
155
|
| `HitlVariant` | type | `dist/nodes/hitl/flow/spec.d.ts:114-139` | Supporting types | WHICH human-task node type to emit. |
|
|
156
156
|
| `http` | function | `dist/nodes/http/flow/factories.d.ts:7-14` | Actions | Declare an HTTP request action, explicitly choosing standalone or managed. |
|
package/dist/api-members.md
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
# Maestro Builder SDK — member index
|
|
7
7
|
|
|
8
|
-
**A lookup table of
|
|
8
|
+
**A lookup table of 1119 rows — match one name in it, do not read it.**
|
|
9
9
|
Grep or search for the member you have; the row it lands on is the whole
|
|
10
10
|
answer. Reading top to bottom costs ~20x what the one row does and tells you
|
|
11
11
|
nothing you asked for.
|
|
12
12
|
|
|
13
|
-
Every field and method of every exported shape —
|
|
13
|
+
Every field and method of every exported shape — 1119 across
|
|
14
14
|
200 interfaces and classes — keyed by the MEMBER's own name.
|
|
15
15
|
|
|
16
16
|
**Use this when the name you have is a field or a method**, which is usually
|
|
@@ -28,7 +28,7 @@ truncated summary would, for less. Read the span.
|
|
|
28
28
|
Member names repeat across shapes (`name` is declared on 69 of them), so the
|
|
29
29
|
`Declared on` column is what makes a match actionable. For the whole shape
|
|
30
30
|
rather than the one field, look that name up in [`api-index.md`](api-index.md) —
|
|
31
|
-
which is why its span is not repeated on all
|
|
31
|
+
which is why its span is not repeated on all 1119 rows here.
|
|
32
32
|
|
|
33
33
|
**A name that misses both indexes is probably not part of the API.** The one
|
|
34
34
|
that comes up is a RUNTIME output key — a human task's `Action`, a step's
|
|
@@ -264,7 +264,7 @@ not find it either.
|
|
|
264
264
|
| `displayName` | property | `RegistryInputField` | `dist/bpmn/typed-node.d.ts:58` |
|
|
265
265
|
| `displayName` | property | `SlaOpts` | `dist/case/case-sdk.d.ts:170-175` |
|
|
266
266
|
| `displayName` | property | `TriggerMeta` | `dist/core/connectors.d.ts:127-128` |
|
|
267
|
-
| `document` | property | `HitlInputs` | `dist/nodes/hitl/flow/spec.d.ts:
|
|
267
|
+
| `document` | property | `HitlInputs` | `dist/nodes/hitl/flow/spec.d.ts:285-289` |
|
|
268
268
|
| `documentClassify` | property | `FlowActionSpecs` | `dist/nodes/document/flow/spec.d.ts:56-59` |
|
|
269
269
|
| `documentId` | property | `DocValidationInputs` | `dist/nodes/hitl/flow/spec.d.ts:84-85` |
|
|
270
270
|
| `doWhile` | method | `StepList` | `dist/flow-sdk.d.ts:1089-1113` |
|
|
@@ -420,7 +420,7 @@ not find it either.
|
|
|
420
420
|
| `height` | property | `StickyNote` | `dist/flow-sdk.d.ts:702-703` |
|
|
421
421
|
| `hidden` | property | `RegistryContextField` | `dist/bpmn/typed-node.d.ts:27` |
|
|
422
422
|
| `hidden` | property | `RegistryInputField` | `dist/bpmn/typed-node.d.ts:61` |
|
|
423
|
-
| `hitl` | property | `FlowActionSpecs` | `dist/nodes/hitl/flow/spec.d.ts:
|
|
423
|
+
| `hitl` | property | `FlowActionSpecs` | `dist/nodes/hitl/flow/spec.d.ts:293-296` |
|
|
424
424
|
| `http` | property | `FlowActionSpecs` | `dist/nodes/http/flow/spec.d.ts:145-148` |
|
|
425
425
|
| `http` | method | `ScopeBuilder` | `dist/bpmn/bpmn-sdk.d.ts:2366-2400` |
|
|
426
426
|
| `humanTask` | method | `ScopeBuilder` | `dist/bpmn/bpmn-sdk.d.ts:2595-2646` |
|
|
@@ -549,6 +549,7 @@ not find it either.
|
|
|
549
549
|
| `layout` | property | `BuiltFlow` | `dist/flow-sdk.d.ts:754-755` |
|
|
550
550
|
| `layout` | method | `CaseBuilder` | `dist/case/case-sdk.d.ts:1139-1159` |
|
|
551
551
|
| `layout` | method | `FlowBuilder` | `dist/flow-sdk.d.ts:1288-1298` |
|
|
552
|
+
| `legacyCompleted` | property | `HitlInputs` | `dist/nodes/hitl/flow/spec.d.ts:252-284` |
|
|
552
553
|
| `limit` | property | `DataFabricReadInputs` | `dist/nodes/data-fabric/flow/spec.d.ts:95-100` |
|
|
553
554
|
| `limit` | property | `DoWhileOptions` | `dist/flow-sdk.d.ts:503-504` |
|
|
554
555
|
| `literal` | property | `Expr` | `dist/core/expr.d.ts:18` |
|
package/dist/check.js
CHANGED
|
@@ -342,9 +342,12 @@ function checkFlow(built, ancestors, opts = {}) {
|
|
|
342
342
|
level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
|
|
343
343
|
message: `Port "${s.port}" routes a per-outcome exit, but human task "${prevAction?.name}" `
|
|
344
344
|
+ `has none — ${ids.length === 0 ? 'it declares no outcomes'
|
|
345
|
-
:
|
|
345
|
+
: model.legacyCompleted ? 'it sets legacyCompleted: true'
|
|
346
|
+
: `the '${prevSpec.inputs?.variant}' variant reads its outcomes from its app`}, so its single `
|
|
346
347
|
+ `exit is 'completed'.`,
|
|
347
|
-
suggestion:
|
|
348
|
+
suggestion: model.legacyCompleted
|
|
349
|
+
? 'drop `legacyCompleted` (and set outcomePorts: true to route each outcome)'
|
|
350
|
+
: `route on out('${prevAction?.name}', 'Action') downstream`,
|
|
348
351
|
});
|
|
349
352
|
}
|
|
350
353
|
else if (!ids.includes(want)) {
|
|
@@ -425,7 +428,11 @@ function checkFlow(built, ancestors, opts = {}) {
|
|
|
425
428
|
const allEnd = outcomes.length > 0
|
|
426
429
|
&& outcomes.every((o) => typeof o !== 'string' && o?.action === 'End');
|
|
427
430
|
// `.stepSwitch` routes every outcome as an arm, so it never belongs here.
|
|
431
|
+
// Nor does a `legacyCompleted` task: only its primary outcome continues,
|
|
432
|
+
// so no downstream switch can reach the others, and HITL_LEGACY_COMPLETED
|
|
433
|
+
// already says so.
|
|
428
434
|
if (outcomes.length > 1 && !allEnd && s0.kind !== 'stepSwitch'
|
|
435
|
+
&& s.spec.inputs?.legacyCompleted !== true
|
|
429
436
|
&& !hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
|
|
430
437
|
hitlChoiceNoPorts.set(s.name, {
|
|
431
438
|
names: outcomes.map((o) => String((typeof o === 'string' ? o : o?.name) ?? '').trim()),
|
|
@@ -68,6 +68,15 @@
|
|
|
68
68
|
* `exposeError: true` or `outcomePorts: true` select `per-outcome` on every node
|
|
69
69
|
* type, including the two app-backed variants, so that `serialize` and `check`
|
|
70
70
|
* refuse them there with their own message rather than silently ignoring them.
|
|
71
|
+
*
|
|
72
|
+
* **`legacyCompleted: true`** is the one way to keep a task with outcomes on the
|
|
73
|
+
* `completed` handle: the `placeholder` shape, marked `legacyCompleted`. It
|
|
74
|
+
* exists so a file saved before flow-workbench #3512, whose task leaves on one
|
|
75
|
+
* `completed` edge, round-trips through decompile and compile unchanged
|
|
76
|
+
* (flow-builder-sdk#1197). It is the file's shape, not one to author: the
|
|
77
|
+
* product runs that edge as the PRIMARY outcome only, and `check` says so
|
|
78
|
+
* (HITL_LEGACY_COMPLETED). `serialize`'s edge guard below exempts exactly the
|
|
79
|
+
* nodes that asked for it.
|
|
71
80
|
*/
|
|
72
81
|
/**
|
|
73
82
|
* The fields of a human task's inputs the routing model reads — structural, so
|
|
@@ -83,6 +92,7 @@ export interface HitlRoutingInputs {
|
|
|
83
92
|
}>;
|
|
84
93
|
readonly outcomePorts?: boolean;
|
|
85
94
|
readonly exposeError?: boolean;
|
|
95
|
+
readonly legacyCompleted?: boolean;
|
|
86
96
|
}
|
|
87
97
|
/**
|
|
88
98
|
* An outcome's stable id — the slug that names its `outcome-<id>` port.
|
|
@@ -114,6 +124,7 @@ export interface HitlOutcomeExit {
|
|
|
114
124
|
export type HitlFanOutReason = 'outcomePorts: false' | 'quick-form' | 'pinned' | 'one outcome' | 'every outcome ends';
|
|
115
125
|
export type HitlExitModel = {
|
|
116
126
|
shape: 'placeholder';
|
|
127
|
+
legacyCompleted?: true;
|
|
117
128
|
} | {
|
|
118
129
|
shape: 'every-outcome';
|
|
119
130
|
exits: HitlOutcomeExit[];
|
|
@@ -151,14 +162,17 @@ type EdgeLike = {
|
|
|
151
162
|
* the product does not draw, and an `outcome-<id>` naming no declared outcome is
|
|
152
163
|
* one `flow validate` refuses ("Edge references undeclared source handle"). A
|
|
153
164
|
* real outcome named "Completed" is `outcome-completed`, which is fine — the
|
|
154
|
-
* bare `completed` never is
|
|
165
|
+
* bare `completed` never is, unless the task asked for it with
|
|
166
|
+
* `legacyCompleted: true` (`legacyCompleted` here).
|
|
155
167
|
*/
|
|
156
|
-
export declare function hitlExitViolation(node: NodeLike, port: string): string | undefined;
|
|
168
|
+
export declare function hitlExitViolation(node: NodeLike, port: string, legacyCompleted?: boolean): string | undefined;
|
|
157
169
|
/**
|
|
158
170
|
* The post-emit half of the guard: every edge of an emitted scope, whatever
|
|
159
171
|
* path produced it (a tail, a `.stepToList` seed, a parked `.stepToRef`), is held
|
|
160
172
|
* to {@link hitlExitViolation}. Fails closed — a serializer that emitted one of
|
|
161
173
|
* these would ship a flow that validates and then drops outcomes at run time.
|
|
174
|
+
* `legacyCompleted` holds the ids of the tasks that set `legacyCompleted: true`,
|
|
175
|
+
* whose `completed` edge is the author's explicit choice.
|
|
162
176
|
*/
|
|
163
|
-
export declare function assertHitlExitInvariant(nodes: readonly NodeLike[], edges: readonly EdgeLike[]): void;
|
|
177
|
+
export declare function assertHitlExitInvariant(nodes: readonly NodeLike[], edges: readonly EdgeLike[], legacyCompleted?: ReadonlySet<string>): void;
|
|
164
178
|
export {};
|
|
@@ -57,6 +57,8 @@ export function hitlExitModel(inputs, pinnedVersion) {
|
|
|
57
57
|
return { shape: 'per-outcome', exits };
|
|
58
58
|
if (!hitlDerivesOutcomeHandles(inputs?.variant) || exits.length === 0)
|
|
59
59
|
return { shape: 'placeholder' };
|
|
60
|
+
if (inputs?.legacyCompleted === true)
|
|
61
|
+
return { shape: 'placeholder', legacyCompleted: true };
|
|
60
62
|
const every = (why) => ({ shape: 'every-outcome', exits, why });
|
|
61
63
|
if (inputs?.outcomePorts === false)
|
|
62
64
|
return every('outcomePorts: false');
|
|
@@ -95,9 +97,10 @@ function outcomeIdsOf(node) {
|
|
|
95
97
|
* the product does not draw, and an `outcome-<id>` naming no declared outcome is
|
|
96
98
|
* one `flow validate` refuses ("Edge references undeclared source handle"). A
|
|
97
99
|
* real outcome named "Completed" is `outcome-completed`, which is fine — the
|
|
98
|
-
* bare `completed` never is
|
|
100
|
+
* bare `completed` never is, unless the task asked for it with
|
|
101
|
+
* `legacyCompleted: true` (`legacyCompleted` here).
|
|
99
102
|
*/
|
|
100
|
-
export function hitlExitViolation(node, port) {
|
|
103
|
+
export function hitlExitViolation(node, port, legacyCompleted = false) {
|
|
101
104
|
if (!OUTCOME_HANDLE_NODE_TYPES.has(String(node.type)))
|
|
102
105
|
return undefined;
|
|
103
106
|
const ids = outcomeIdsOf(node);
|
|
@@ -105,6 +108,8 @@ export function hitlExitViolation(node, port) {
|
|
|
105
108
|
return undefined;
|
|
106
109
|
const declared = ids.map((i) => `outcome-${i}`).join(', ');
|
|
107
110
|
if (port === 'completed') {
|
|
111
|
+
if (legacyCompleted)
|
|
112
|
+
return undefined;
|
|
108
113
|
return `human task "${String(node.id)}" has outcomes (${declared}), so the product draws no `
|
|
109
114
|
+ `'completed' handle on it — an edge there is remapped to the primary outcome and every other `
|
|
110
115
|
+ `outcome loses its path.`;
|
|
@@ -119,14 +124,16 @@ export function hitlExitViolation(node, port) {
|
|
|
119
124
|
* path produced it (a tail, a `.stepToList` seed, a parked `.stepToRef`), is held
|
|
120
125
|
* to {@link hitlExitViolation}. Fails closed — a serializer that emitted one of
|
|
121
126
|
* these would ship a flow that validates and then drops outcomes at run time.
|
|
127
|
+
* `legacyCompleted` holds the ids of the tasks that set `legacyCompleted: true`,
|
|
128
|
+
* whose `completed` edge is the author's explicit choice.
|
|
122
129
|
*/
|
|
123
|
-
export function assertHitlExitInvariant(nodes, edges) {
|
|
130
|
+
export function assertHitlExitInvariant(nodes, edges, legacyCompleted = new Set()) {
|
|
124
131
|
const byId = new Map(nodes.map((n) => [String(n.id), n]));
|
|
125
132
|
for (const e of edges) {
|
|
126
133
|
const node = byId.get(String(e.sourceNodeId));
|
|
127
134
|
if (!node)
|
|
128
135
|
continue;
|
|
129
|
-
const why = hitlExitViolation(node, String(e.sourcePort));
|
|
136
|
+
const why = hitlExitViolation(node, String(e.sourcePort), legacyCompleted.has(String(e.sourceNodeId)));
|
|
130
137
|
if (why)
|
|
131
138
|
throw new Error(`serialize: ${why}`);
|
|
132
139
|
}
|
package/dist/decompile-cli.js
CHANGED
|
@@ -105,11 +105,16 @@ function pipelineScript(originalPath, tsPath, pipelinePath) {
|
|
|
105
105
|
// Generated by flow-decompile — the brownfield read/modify/write pipeline for
|
|
106
106
|
// ${commentText(basename(originalPath))}. Run it, edit ${commentText(basename(tsPath))}, run it again.
|
|
107
107
|
${pipelineFrontDoor('flow')}
|
|
108
|
+
import { mkdirSync } from 'node:fs';
|
|
108
109
|
|
|
109
110
|
const ORIGINAL = ${JSON.stringify(originalPath)};
|
|
110
111
|
const TS = ${JSON.stringify(tsPath)};
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// The baseline and the edited compile each get a directory of their own: a
|
|
113
|
+
// compile writes an inline agent's \`<source>/agent.json\` beside its output, and
|
|
114
|
+
// merge reads both copies to carry a prompt or settings edit (#1207). In one
|
|
115
|
+
// directory the edited compile would overwrite the baseline's.
|
|
116
|
+
const BASELINE = ${JSON.stringify(join(base + '.baseline', basename(base) + '.flow'))};
|
|
117
|
+
const EDITED = ${JSON.stringify(join(base + '.edited', basename(base) + '.flow'))};
|
|
113
118
|
const MERGED = ${JSON.stringify(base + '.merged.flow')};
|
|
114
119
|
|
|
115
120
|
// Ensure the decompiled source exists (a fresh clone may have only the .flow).
|
|
@@ -118,10 +123,12 @@ if (!existsSync(TS)) run('decompile', [ORIGINAL, '-o', TS, '--no-pipeline']);
|
|
|
118
123
|
if (!existsSync(BASELINE)) {
|
|
119
124
|
// ── Step 1: capture the baseline = compile of the PRISTINE decompiled code,
|
|
120
125
|
// BEFORE any edit. Run this once right after decompiling, then edit. ──
|
|
126
|
+
mkdirSync(dirname(BASELINE), { recursive: true });
|
|
121
127
|
run('compile', [TS, '-o', BASELINE, '--no-check']);
|
|
122
128
|
console.log('\\n>>> Baseline captured. Now EDIT ' + ${JSON.stringify(basename(tsPath))} + ', then re-run this script to compile + merge.');
|
|
123
129
|
} else {
|
|
124
130
|
// ── Step 2: compile your edits, then merge them back into the original. ──
|
|
131
|
+
mkdirSync(dirname(EDITED), { recursive: true });
|
|
125
132
|
run('compile', [TS, '-o', EDITED, '--no-check']);
|
|
126
133
|
|
|
127
134
|
// ── DIFF GATE (seam) ──────────────────────────────────────────────────────
|
|
@@ -142,7 +149,8 @@ if (!existsSync(BASELINE)) {
|
|
|
142
149
|
const args = namedOriginal ? [EDITED, '--original', ORIGINAL, '-o', MERGED] : [ORIGINAL, EDITED, '-o', MERGED];
|
|
143
150
|
if (existsSync(BASELINE)) args.push('--baseline', BASELINE);
|
|
144
151
|
run('merge', args);
|
|
145
|
-
console.log('\\n>>> Wrote ' + ${JSON.stringify(base + '.merged.flow')}
|
|
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.');
|
|
146
154
|
}
|
|
147
155
|
`;
|
|
148
156
|
}
|
package/dist/flow-node-type.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface FlowNodeContribution extends Omit<NodeContribution, 'definition
|
|
|
52
52
|
readonly port: string;
|
|
53
53
|
readonly endsRun?: boolean;
|
|
54
54
|
}>;
|
|
55
|
+
/**
|
|
56
|
+
* The node is a human task with outcomes that leaves on its legacy `completed`
|
|
57
|
+
* handle, because its author set `legacyCompleted: true`. Exempts it from the
|
|
58
|
+
* guard that refuses a `completed` edge on such a task (`hitlExitViolation`).
|
|
59
|
+
*/
|
|
60
|
+
legacyCompleted?: boolean;
|
|
55
61
|
/**
|
|
56
62
|
* `outputs` REPLACES the definition's `outputDefinition` on the node instead of
|
|
57
63
|
* being merged over it — for an instance whose output slots are its own (a
|
package/dist/merge-cli.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type SidecarSet } from './merge-sidecars.js';
|
|
2
3
|
import { type CliExitCode } from './cli-run.js';
|
|
4
|
+
/**
|
|
5
|
+
* The agent sidecars beside the flow at `flowPath`: `<source>/agent.json` and each
|
|
6
|
+
* `<source>/resources/<id>/resource.json`, parsed. An unreadable or invalid file
|
|
7
|
+
* reads as absent.
|
|
8
|
+
*/
|
|
9
|
+
export declare function sidecarsBeside(flowPath: string): SidecarSet;
|
|
3
10
|
export declare function run(argv: string[]): Promise<CliExitCode>;
|
package/dist/merge-cli.js
CHANGED
|
@@ -18,12 +18,26 @@
|
|
|
18
18
|
* With `--baseline`, every wire-identity difference between the baseline and
|
|
19
19
|
* the original (what the no-edit round trip changed) is printed as a warning;
|
|
20
20
|
* `--strict` refuses instead (exit 1, nothing written). See merge.ts.
|
|
21
|
+
*
|
|
22
|
+
* Agent sidecars (#1207): an inline agent's prompts and settings live in
|
|
23
|
+
* `<source>/agent.json` beside each flow, not in the node. Merge reads that file
|
|
24
|
+
* beside the original, the baseline and the edited flow, applies the author's
|
|
25
|
+
* change (baseline -> edited) onto the original's copy, and writes the result
|
|
26
|
+
* beside the merged output, one `flow-merge: wrote <source>/agent.json` line per
|
|
27
|
+
* file. Copy those files along with the merged `.flow`. The baseline's sidecars
|
|
28
|
+
* are only known when the baseline and the edited flow are in different
|
|
29
|
+
* directories (each compile writes `<source>/` beside its output), or when merge
|
|
30
|
+
* compiled the side itself (`--baseline-from`, an edited `.flow.ts`), which keeps
|
|
31
|
+
* the sidecars in memory; what merge cannot carry is warned, and refused under
|
|
32
|
+
* `--strict`. See merge-sidecars.ts.
|
|
21
33
|
*/
|
|
22
34
|
import { reportResult } from './cli-result.js';
|
|
23
35
|
import { writeJsonFile } from './core/json-file.js';
|
|
24
|
-
import { readFileSync } from 'node:fs';
|
|
25
|
-
import { basename, resolve } from 'node:path';
|
|
26
|
-
import { MergeRefusedError,
|
|
36
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { MergeRefusedError, mergeFlowWithSidecars } from './merge.js';
|
|
39
|
+
import { sidecarSetFromList } from './merge-sidecars.js';
|
|
40
|
+
import { isWithin } from './path-within.js';
|
|
27
41
|
import { flagValue, runWhenInvokedDirectly } from './cli-run.js';
|
|
28
42
|
import { ensureTypeScriptRuntime } from './node-runtime.js';
|
|
29
43
|
import { flowCheckOpts, openCliLibrary } from './cli-library.js';
|
|
@@ -32,6 +46,38 @@ import { defaultBindingsFile } from './workdir.js';
|
|
|
32
46
|
import { baselineFromOriginal, checkEdited, classifyDrafts, compileSourceDraftTolerant } from './merge-compile.js';
|
|
33
47
|
const USAGE = 'usage: flow-merge <original.flow> <edited.flow | Edited.flow.ts> [-o merged.flow] '
|
|
34
48
|
+ '[--baseline <baseline.flow> | --baseline-from <original.flow>] [--library <dir>] [--bindings <file>] [--strict]';
|
|
49
|
+
/**
|
|
50
|
+
* The agent sidecars beside the flow at `flowPath`: `<source>/agent.json` and each
|
|
51
|
+
* `<source>/resources/<id>/resource.json`, parsed. An unreadable or invalid file
|
|
52
|
+
* reads as absent.
|
|
53
|
+
*/
|
|
54
|
+
export function sidecarsBeside(flowPath) {
|
|
55
|
+
const dir = dirname(resolve(flowPath));
|
|
56
|
+
const read = (rel) => {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(join(dir, rel), 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return (source) => {
|
|
65
|
+
const agent = read(`${source}/agent.json`);
|
|
66
|
+
if (agent === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
const files = { [`${source}/agent.json`]: agent };
|
|
69
|
+
const resourcesDir = join(dir, source, 'resources');
|
|
70
|
+
if (existsSync(resourcesDir)) {
|
|
71
|
+
for (const entry of readdirSync(resourcesDir)) {
|
|
72
|
+
const rel = `${source}/resources/${entry}/resource.json`;
|
|
73
|
+
const json = read(rel);
|
|
74
|
+
if (json !== undefined)
|
|
75
|
+
files[rel] = json;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return files;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
35
81
|
export async function run(argv) {
|
|
36
82
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
37
83
|
console.error(USAGE);
|
|
@@ -62,6 +108,7 @@ export async function run(argv) {
|
|
|
62
108
|
let drafts = [];
|
|
63
109
|
let diagnostics = [];
|
|
64
110
|
let edited;
|
|
111
|
+
let editedSidecars;
|
|
65
112
|
if (editedIsSource) {
|
|
66
113
|
// The edited source gets what `compile` gives it: check() with the same
|
|
67
114
|
// options, and the serializer's warnings (CONNECTION_STUB, ...) printed.
|
|
@@ -70,14 +117,18 @@ export async function run(argv) {
|
|
|
70
117
|
warn: (w) => console.error(` ⚠ [${w.code}] ${w.message}`),
|
|
71
118
|
});
|
|
72
119
|
edited = compiled.flow;
|
|
120
|
+
editedSidecars = compiled.sidecars;
|
|
73
121
|
drafts = compiled.drafts;
|
|
74
122
|
diagnostics = checkEdited(compiled.built, edited, flowCheckOpts(library, bindings));
|
|
75
123
|
}
|
|
76
124
|
else {
|
|
77
125
|
edited = JSON.parse(readFileSync(editedPath, 'utf8'));
|
|
78
126
|
}
|
|
79
|
-
const
|
|
80
|
-
?
|
|
127
|
+
const baselineCompile = baselineFrom
|
|
128
|
+
? await baselineFromOriginal(baselineFrom, compileOpts, (m) => console.error(`flow-merge: decompile warning: ${m}`))
|
|
129
|
+
: undefined;
|
|
130
|
+
const baselineSidecars = baselineCompile?.sidecars;
|
|
131
|
+
const baseline = baselineCompile ? baselineCompile.flow
|
|
81
132
|
: baselinePath ? JSON.parse(readFileSync(baselinePath, 'utf8')) : undefined;
|
|
82
133
|
const strict = argv.includes('--strict');
|
|
83
134
|
if (strict && !baseline) {
|
|
@@ -107,9 +158,25 @@ export async function run(argv) {
|
|
|
107
158
|
console.error(`flow-merge: ${errors.length} error(s) in nodes merge takes from the edited flow; finish them or revert the edit. Nothing written.`);
|
|
108
159
|
return 1;
|
|
109
160
|
}
|
|
161
|
+
// Each compile writes `<source>/` beside its own output, so a baseline in the
|
|
162
|
+
// edited flow's directory had its sidecars overwritten by the edited compile.
|
|
163
|
+
// A side merge compiled itself (--baseline-from, an edited .flow.ts) carries
|
|
164
|
+
// its sidecars in memory instead.
|
|
165
|
+
const dirOf = (p) => dirname(resolve(p));
|
|
166
|
+
const baselineShared = baselinePath !== undefined && !editedIsSource && dirOf(baselinePath) === dirOf(editedPath);
|
|
167
|
+
const sources = {
|
|
168
|
+
original: sidecarsBeside(originalPath),
|
|
169
|
+
edited: editedSidecars ? sidecarSetFromList(editedSidecars) : sidecarsBeside(editedPath),
|
|
170
|
+
baseline: baselineSidecars ? sidecarSetFromList(baselineSidecars)
|
|
171
|
+
: baselinePath !== undefined && !baselineShared ? sidecarsBeside(baselinePath) : undefined,
|
|
172
|
+
baselineMissing: baselinePath === undefined
|
|
173
|
+
? 'no --baseline or --baseline-from was given'
|
|
174
|
+
: `the baseline and the edited flow are in one directory (${dirOf(editedPath)}), so the edited compile `
|
|
175
|
+
+ 'overwrote the baseline\'s agent sidecars',
|
|
176
|
+
};
|
|
110
177
|
let merged;
|
|
111
178
|
try {
|
|
112
|
-
merged =
|
|
179
|
+
merged = mergeFlowWithSidecars(original, edited, sources, {
|
|
113
180
|
baseline,
|
|
114
181
|
strict,
|
|
115
182
|
warn: (m) => console.error(`flow-merge: warning: ${m}`),
|
|
@@ -122,9 +189,28 @@ export async function run(argv) {
|
|
|
122
189
|
return 1;
|
|
123
190
|
}
|
|
124
191
|
const out = flagValue(argv, '-o') ?? 'merged.flow';
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
192
|
+
const outDir = dirOf(out);
|
|
193
|
+
const targets = merged.sidecars.map((s) => ({ sidecar: s, target: resolve(outDir, s.path) }));
|
|
194
|
+
const escaping = targets.find(({ target }) => !isWithin(outDir, target));
|
|
195
|
+
if (escaping) {
|
|
196
|
+
console.error(`flow-merge: refusing to write a sidecar outside the merged flow's directory: ${escaping.sidecar.path}`);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
const flow = merged.flow;
|
|
200
|
+
mkdirSync(outDir, { recursive: true });
|
|
201
|
+
writeJsonFile(out, flow);
|
|
202
|
+
console.log(`flow-merge: wrote ${basename(out)} (${(flow.nodes ?? []).length} nodes, ${(flow.edges ?? []).length} edges)`);
|
|
203
|
+
for (const { sidecar, target } of targets) {
|
|
204
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
205
|
+
writeJsonFile(target, sidecar.json);
|
|
206
|
+
console.log(`flow-merge: wrote ${sidecar.path} (carries ${sidecar.carried.join(', ')})`);
|
|
207
|
+
}
|
|
208
|
+
reportResult({
|
|
209
|
+
path: resolve(out),
|
|
210
|
+
nodes: (flow.nodes ?? []).length,
|
|
211
|
+
edges: (flow.edges ?? []).length,
|
|
212
|
+
...(targets.length > 0 ? { sidecars: targets.map((t) => t.target) } : {}),
|
|
213
|
+
});
|
|
128
214
|
return 0;
|
|
129
215
|
}
|
|
130
216
|
runWhenInvokedDirectly(import.meta.url, 'flow-merge', run);
|
package/dist/merge-compile.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { type DraftFinding, type SerializeOpts } from './serialize.js';
|
|
2
2
|
import { type CheckOpts, type Diagnostic } from './check.js';
|
|
3
3
|
import type { BuiltFlow } from './flow-sdk.js';
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* A compile's flow, the draft refusals it reported instead of throwing, and the
|
|
6
|
+
* agent sidecars (`<source>/agent.json`, resource files) it produced, none written.
|
|
7
|
+
*/
|
|
5
8
|
export interface DraftTolerantCompile {
|
|
6
9
|
flow: any;
|
|
7
10
|
drafts: DraftFinding[];
|
|
11
|
+
sidecars: Array<{
|
|
12
|
+
path: string;
|
|
13
|
+
json: unknown;
|
|
14
|
+
}>;
|
|
8
15
|
}
|
|
9
16
|
/** Compile `built` with draft tolerance. Every other refusal still throws. */
|
|
10
17
|
export declare function compileDraftTolerant(built: BuiltFlow, opts?: Omit<SerializeOpts, 'draft'>): DraftTolerantCompile;
|
package/dist/merge-compile.js
CHANGED
|
@@ -18,8 +18,9 @@ import { PACKAGE_NAME } from './package-name.js';
|
|
|
18
18
|
/** Compile `built` with draft tolerance. Every other refusal still throws. */
|
|
19
19
|
export function compileDraftTolerant(built, opts = {}) {
|
|
20
20
|
const drafts = [];
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const sidecars = [];
|
|
22
|
+
const flow = serialize(built, { ...opts, draft: (f) => drafts.push(f), sidecars });
|
|
23
|
+
return { flow, drafts, sidecars };
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
26
|
* Load a `.flow.ts` source and compile it with draft tolerance.
|
|
@@ -33,7 +33,7 @@ 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';
|
|
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';
|
|
37
37
|
/** The finding, phrased for the author, naming the node, edge or row. */
|
|
38
38
|
message: string;
|
|
39
39
|
/**
|
package/dist/merge-roundtrip.js
CHANGED
|
@@ -373,7 +373,8 @@ export class MergeRefusedError extends Error {
|
|
|
373
373
|
/** Every refusing finding, in report order. */
|
|
374
374
|
findings;
|
|
375
375
|
constructor(findings) {
|
|
376
|
-
super(`refused (--strict): the merged file would differ from the original where the author made no edit
|
|
376
|
+
super(`refused (--strict): the merged file would differ from the original where the author made no edit, `
|
|
377
|
+
+ `or would leave out an edit the author made:\n`
|
|
377
378
|
+ findings.map((f) => ` - ${f.message}`).join('\n'));
|
|
378
379
|
this.name = 'MergeRefusedError';
|
|
379
380
|
this.findings = findings;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { MergeFinding } from './merge-roundtrip.js';
|
|
2
|
+
/**
|
|
3
|
+
* The sidecar files of one side of the merge, per agent `source`: every file
|
|
4
|
+
* under `<source>/` that merge understands, keyed by its path relative to the
|
|
5
|
+
* flow (`<source>/agent.json`, `<source>/resources/<id>/resource.json`), parsed.
|
|
6
|
+
* `undefined` when that side has no `<source>/agent.json`.
|
|
7
|
+
*/
|
|
8
|
+
export type SidecarSet = (source: string) => Record<string, unknown> | undefined;
|
|
9
|
+
/** The three sides' sidecars, as {@link mergeAgentSidecars} reads them. */
|
|
10
|
+
export interface MergeSidecarSources {
|
|
11
|
+
original: SidecarSet;
|
|
12
|
+
edited: SidecarSet;
|
|
13
|
+
/**
|
|
14
|
+
* The baseline compile's sidecars. Leave it out when they are not known, and
|
|
15
|
+
* say why in {@link baselineMissing}: merge then cannot tell an edit from the
|
|
16
|
+
* round trip's own changes, and reports instead of carrying.
|
|
17
|
+
*/
|
|
18
|
+
baseline?: SidecarSet;
|
|
19
|
+
/** Why {@link baseline} is absent, phrased to finish "merge cannot tell an edit from the round trip: …". */
|
|
20
|
+
baselineMissing?: string;
|
|
21
|
+
}
|
|
22
|
+
/** One file the merged flow needs written beside it, relative to it. */
|
|
23
|
+
export interface SidecarWrite {
|
|
24
|
+
path: string;
|
|
25
|
+
json: unknown;
|
|
26
|
+
/** What it carries, for the report: the changed `agent.json` paths, or `new agent`. */
|
|
27
|
+
carried: string[];
|
|
28
|
+
}
|
|
29
|
+
/** The `{ path, json }` list `serializeWithSidecars` returns, as a {@link SidecarSet}. */
|
|
30
|
+
export declare function sidecarSetFromList(list: ReadonlyArray<{
|
|
31
|
+
path: string;
|
|
32
|
+
json: unknown;
|
|
33
|
+
}>): SidecarSet;
|
|
34
|
+
interface AnyNode {
|
|
35
|
+
id: string;
|
|
36
|
+
inputs?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The agent `source` directories a flow's nodes point at, root and subflows,
|
|
40
|
+
* each with the first node that does. A `source` is a single directory name
|
|
41
|
+
* (the agent's uuid); anything else (an expression, a path) is not a sidecar
|
|
42
|
+
* directory and is skipped.
|
|
43
|
+
*/
|
|
44
|
+
export declare function agentSources(flow: {
|
|
45
|
+
nodes?: AnyNode[];
|
|
46
|
+
subflows?: Record<string, unknown>;
|
|
47
|
+
} | undefined): Map<string, AnyNode>;
|
|
48
|
+
/**
|
|
49
|
+
* Three-way merge of one JSON value: `original` with the change from `baseline`
|
|
50
|
+
* to `edited` applied. Objects merge key by key; arrays merge index by index
|
|
51
|
+
* when all three have the same length (a prompt edit changes `messages[0]` and
|
|
52
|
+
* leaves `messages[1]` as the original has it), and are replaced whole otherwise.
|
|
53
|
+
* Every replaced path is pushed to `changed`.
|
|
54
|
+
*
|
|
55
|
+
* For example original `{a: 1, b: 2}`, baseline `{a: 1}`, edited `{a: 3}` gives
|
|
56
|
+
* `{a: 3, b: 2}` and `changed = ["a"]`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function mergeJson3(original: unknown, baseline: unknown, edited: unknown, changed: string[], path?: string): unknown;
|
|
59
|
+
/**
|
|
60
|
+
* The sidecar files the merged flow needs written beside it, and what merge could
|
|
61
|
+
* not carry. `merged` is `mergeFlow`'s result. An agent is new when the
|
|
62
|
+
* original side has no `<source>/agent.json` for it.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mergeAgentSidecars(merged: {
|
|
65
|
+
nodes?: AnyNode[];
|
|
66
|
+
subflows?: Record<string, unknown>;
|
|
67
|
+
}, sources: MergeSidecarSources): {
|
|
68
|
+
writes: SidecarWrite[];
|
|
69
|
+
findings: MergeFinding[];
|
|
70
|
+
};
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-sidecars — carry an agent's sidecar edits through a brownfield merge (#1207).
|
|
3
|
+
*
|
|
4
|
+
* An inline agent is TWO artifacts: the node in the `.flow`, and the agent's own
|
|
5
|
+
* configuration in `<source>/agent.json` beside it (plus a
|
|
6
|
+
* `<source>/resources/<id>/resource.json` per attached resource). Since #927 the
|
|
7
|
+
* node is a shell: the prompts, the model and the run settings live ONLY in the
|
|
8
|
+
* sidecar. So an edit to a prompt leaves the agent node byte-equal in the baseline
|
|
9
|
+
* and the edited compile, `mergeFlow` keeps the original node (correctly: the node
|
|
10
|
+
* did not change), and the only artifact that changed is a file merge used not to
|
|
11
|
+
* read. The edit was lost without a word.
|
|
12
|
+
*
|
|
13
|
+
* This module is merge's view of those files. For every agent the merged flow
|
|
14
|
+
* keeps, it computes what the author changed in `agent.json` (baseline -> edited)
|
|
15
|
+
* and applies exactly that onto the ORIGINAL's `agent.json`:
|
|
16
|
+
*
|
|
17
|
+
* original { model: "gpt-5.4", maxIterations: 25, system: "Summarize..." }
|
|
18
|
+
* baseline { model: "gpt-5.4", system: "Summarize..." } <- round trip drops maxIterations
|
|
19
|
+
* edited { model: "gpt-5.4", system: "EDITED..." }
|
|
20
|
+
* merged { model: "gpt-5.4", maxIterations: 25, system: "EDITED..." }
|
|
21
|
+
*
|
|
22
|
+
* Taking `edited`'s file whole would carry the round trip's own changes too
|
|
23
|
+
* (`maxIterations` dropped above; on a canvas flow, a dropped `description`, a
|
|
24
|
+
* default model filled in). A delta carries the edit and nothing else, the same
|
|
25
|
+
* rule `mergeFlow` applies to nodes.
|
|
26
|
+
*
|
|
27
|
+
* What it cannot carry, it reports as a {@link MergeFinding} that `strict`
|
|
28
|
+
* refuses, so no sidecar edit can disappear silently:
|
|
29
|
+
*
|
|
30
|
+
* - no baseline sidecar to compare with (no `--baseline`, or the baseline and the
|
|
31
|
+
* edited compile were written to one directory, so the second overwrote the
|
|
32
|
+
* first's `<source>/`), and the edited `agent.json` differs from the original's;
|
|
33
|
+
* - a change to the agent's resource files (a context or tool added or removed);
|
|
34
|
+
* - an `agent.json` edit on a node that carries its own prompts (an embedded
|
|
35
|
+
* node: decompile, like Studio Web, reads the node's prompts first, so the
|
|
36
|
+
* edited file would not be what runs).
|
|
37
|
+
*
|
|
38
|
+
* An agent the merged flow adds (no sidecar in the original) gets the edited
|
|
39
|
+
* compile's files whole.
|
|
40
|
+
*
|
|
41
|
+
* Pure: the caller reads the files ({@link SidecarSet}) and writes the result.
|
|
42
|
+
*/
|
|
43
|
+
import { jsonClone } from './core/json-file.js';
|
|
44
|
+
/** The `{ path, json }` list `serializeWithSidecars` returns, as a {@link SidecarSet}. */
|
|
45
|
+
export function sidecarSetFromList(list) {
|
|
46
|
+
return (source) => {
|
|
47
|
+
const files = {};
|
|
48
|
+
for (const s of list)
|
|
49
|
+
if (s.path.startsWith(`${source}/`))
|
|
50
|
+
files[s.path] = s.json;
|
|
51
|
+
return files[`${source}/agent.json`] === undefined ? undefined : files;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The agent `source` directories a flow's nodes point at, root and subflows,
|
|
56
|
+
* each with the first node that does. A `source` is a single directory name
|
|
57
|
+
* (the agent's uuid); anything else (an expression, a path) is not a sidecar
|
|
58
|
+
* directory and is skipped.
|
|
59
|
+
*/
|
|
60
|
+
export function agentSources(flow) {
|
|
61
|
+
const out = new Map();
|
|
62
|
+
const walk = (scope) => {
|
|
63
|
+
const s = scope;
|
|
64
|
+
for (const n of Array.isArray(s?.nodes) ? s.nodes : []) {
|
|
65
|
+
const src = n?.inputs?.source;
|
|
66
|
+
if (typeof src === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(src) && !out.has(src))
|
|
67
|
+
out.set(src, n);
|
|
68
|
+
}
|
|
69
|
+
if (s?.subflows && typeof s.subflows === 'object')
|
|
70
|
+
Object.values(s.subflows).forEach(walk);
|
|
71
|
+
};
|
|
72
|
+
walk(flow);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** JSON with object keys sorted, so key order is not a difference. */
|
|
76
|
+
function stable(v) {
|
|
77
|
+
return JSON.stringify(v, (_k, val) => (val && typeof val === 'object' && !Array.isArray(val)
|
|
78
|
+
? Object.fromEntries(Object.keys(val).sort().map((k) => [k, val[k]]))
|
|
79
|
+
: val));
|
|
80
|
+
}
|
|
81
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
82
|
+
/**
|
|
83
|
+
* Three-way merge of one JSON value: `original` with the change from `baseline`
|
|
84
|
+
* to `edited` applied. Objects merge key by key; arrays merge index by index
|
|
85
|
+
* when all three have the same length (a prompt edit changes `messages[0]` and
|
|
86
|
+
* leaves `messages[1]` as the original has it), and are replaced whole otherwise.
|
|
87
|
+
* Every replaced path is pushed to `changed`.
|
|
88
|
+
*
|
|
89
|
+
* For example original `{a: 1, b: 2}`, baseline `{a: 1}`, edited `{a: 3}` gives
|
|
90
|
+
* `{a: 3, b: 2}` and `changed = ["a"]`.
|
|
91
|
+
*/
|
|
92
|
+
export function mergeJson3(original, baseline, edited, changed, path = '') {
|
|
93
|
+
if (stable(baseline) === stable(edited))
|
|
94
|
+
return jsonClone(original);
|
|
95
|
+
if (isObject(original) && isObject(baseline) && isObject(edited)) {
|
|
96
|
+
const out = { ...jsonClone(original) };
|
|
97
|
+
for (const k of new Set([...Object.keys(baseline), ...Object.keys(edited)])) {
|
|
98
|
+
if (stable(baseline[k]) === stable(edited[k]))
|
|
99
|
+
continue;
|
|
100
|
+
const at = path ? `${path}.${k}` : k;
|
|
101
|
+
if (edited[k] === undefined) {
|
|
102
|
+
delete out[k];
|
|
103
|
+
changed.push(at);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
out[k] = mergeJson3(original[k], baseline[k], edited[k], changed, at);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(original) && Array.isArray(baseline) && Array.isArray(edited)
|
|
112
|
+
&& original.length === baseline.length && baseline.length === edited.length) {
|
|
113
|
+
return original.map((o, i) => mergeJson3(o, baseline[i], edited[i], changed, `${path}[${i}]`));
|
|
114
|
+
}
|
|
115
|
+
changed.push(path || '(whole file)');
|
|
116
|
+
return jsonClone(edited);
|
|
117
|
+
}
|
|
118
|
+
/** A resource file's identity: its content without the `id` (and directory) the writer derives. */
|
|
119
|
+
function resourceIdentities(files) {
|
|
120
|
+
const out = new Map();
|
|
121
|
+
for (const [path, json] of Object.entries(files)) {
|
|
122
|
+
if (!/\/resources\/[^/]+\/resource\.json$/.test(path))
|
|
123
|
+
continue;
|
|
124
|
+
const { id: _id, ...rest } = (isObject(json) ? json : {});
|
|
125
|
+
out.set(stable(rest), path);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The resource files `to` adds or removes relative to `from`, by identity
|
|
131
|
+
* (content without `id`), as `added <path>; removed <path>`; `undefined` when
|
|
132
|
+
* the sets are equal.
|
|
133
|
+
*/
|
|
134
|
+
function resourceChanges(from, to) {
|
|
135
|
+
const a = resourceIdentities(from);
|
|
136
|
+
const b = resourceIdentities(to);
|
|
137
|
+
const out = [
|
|
138
|
+
...[...b.keys()].filter((k) => !a.has(k)).map((k) => `added ${b.get(k)}`),
|
|
139
|
+
...[...a.keys()].filter((k) => !b.has(k)).map((k) => `removed ${a.get(k)}`),
|
|
140
|
+
];
|
|
141
|
+
return out.length > 0 ? out.join('; ') : undefined;
|
|
142
|
+
}
|
|
143
|
+
/** Whether a node carries its prompts itself, so its `agent.json` is not what runs (see decompile's `sidecarAgentInputs`). */
|
|
144
|
+
function carriesOwnPrompts(node) {
|
|
145
|
+
const i = node?.inputs ?? {};
|
|
146
|
+
return typeof i.systemPrompt === 'string' || typeof i.userPrompt === 'string';
|
|
147
|
+
}
|
|
148
|
+
const finding = (message) => ({ kind: 'agent-sidecar', message, refuses: true });
|
|
149
|
+
/**
|
|
150
|
+
* The sidecar files the merged flow needs written beside it, and what merge could
|
|
151
|
+
* not carry. `merged` is `mergeFlow`'s result. An agent is new when the
|
|
152
|
+
* original side has no `<source>/agent.json` for it.
|
|
153
|
+
*/
|
|
154
|
+
export function mergeAgentSidecars(merged, sources) {
|
|
155
|
+
const writes = [];
|
|
156
|
+
const findings = [];
|
|
157
|
+
for (const [source, node] of agentSources(merged)) {
|
|
158
|
+
const edited = sources.edited(source);
|
|
159
|
+
if (edited === undefined)
|
|
160
|
+
continue; // the edited compile wrote no sidecar here: nothing to carry
|
|
161
|
+
const orig = sources.original(source);
|
|
162
|
+
const agentPath = `${source}/agent.json`;
|
|
163
|
+
if (orig === undefined) {
|
|
164
|
+
// An agent the edit added (or one whose original sidecar is missing):
|
|
165
|
+
// the edited compile's files are the only ones there are.
|
|
166
|
+
for (const [path, json] of Object.entries(edited))
|
|
167
|
+
writes.push({ path, json: jsonClone(json), carried: ['new agent'] });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const base = sources.baseline?.(source);
|
|
171
|
+
if (base === undefined) {
|
|
172
|
+
if (stable(orig[agentPath]) !== stable(edited[agentPath])) {
|
|
173
|
+
const why = sources.baselineMissing
|
|
174
|
+
?? `the baseline compile wrote no ${agentPath}`;
|
|
175
|
+
findings.push(finding(`agent "${node.id}": the edited ${agentPath} differs from the original's, and merge cannot tell an edit `
|
|
176
|
+
+ `from the round trip's own changes: ${why}. The change is NOT carried; the original's ${agentPath} stays. `
|
|
177
|
+
+ 'Compile the baseline and the edited source into two different directories and pass --baseline.'));
|
|
178
|
+
}
|
|
179
|
+
// A context or tool added or removed changes no agent.json, so compare the
|
|
180
|
+
// resource files too, against the original's this time.
|
|
181
|
+
const res = resourceChanges(orig, edited);
|
|
182
|
+
if (res) {
|
|
183
|
+
const why = sources.baselineMissing ?? 'the baseline compile wrote no sidecars';
|
|
184
|
+
findings.push(finding(`agent "${node.id}": the edited resource files differ from the original's (${res}), and merge cannot tell `
|
|
185
|
+
+ `an edit from the round trip's own changes: ${why}. Merge does not carry resource files; the original's `
|
|
186
|
+
+ 'stay as they are.'));
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
// agent.json: the author's delta, applied onto the original.
|
|
191
|
+
const changed = [];
|
|
192
|
+
const next = mergeJson3(orig[agentPath], base[agentPath], edited[agentPath], changed);
|
|
193
|
+
if (changed.length > 0) {
|
|
194
|
+
// `node` is the merged node: the original's when merge kept it, the
|
|
195
|
+
// edited shell when the author changed the node too.
|
|
196
|
+
if (carriesOwnPrompts(node)) {
|
|
197
|
+
findings.push(finding(`agent "${node.id}": its ${agentPath} was edited (${changed.join(', ')}), but the original node carries its `
|
|
198
|
+
+ 'own prompts, which are read before agent.json, so the edit would not reach the agent. The change is NOT '
|
|
199
|
+
+ 'carried. Make the change on the canvas, or save the agent with its prompts in agent.json only.'));
|
|
200
|
+
}
|
|
201
|
+
else if (stable(next) !== stable(orig[agentPath])) {
|
|
202
|
+
writes.push({ path: agentPath, json: next, carried: changed });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Resources: report a changed set; the canvas names them by its own ids, so
|
|
206
|
+
// the edited files cannot be mapped onto the original's directories.
|
|
207
|
+
const res = resourceChanges(base, edited);
|
|
208
|
+
if (res) {
|
|
209
|
+
findings.push(finding(`agent "${node.id}": the edit changes its resource files (${res}). Merge does not carry resource files; `
|
|
210
|
+
+ 'the original\'s stay as they are. Make that change on the canvas.'));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { writes, findings };
|
|
214
|
+
}
|
package/dist/merge.d.ts
CHANGED
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
* checked for an edge that leaves a handle its kept source node does not
|
|
75
75
|
* declare. See merge-roundtrip.ts.
|
|
76
76
|
*/
|
|
77
|
+
import { type MergeSidecarSources, type SidecarWrite } from './merge-sidecars.js';
|
|
77
78
|
export { MergeRefusedError, type MergeFinding } from './merge-roundtrip.js';
|
|
79
|
+
export type { MergeSidecarSources, SidecarSet, SidecarWrite } from './merge-sidecars.js';
|
|
78
80
|
interface AnyNode {
|
|
79
81
|
id: string;
|
|
80
82
|
type: string;
|
|
@@ -143,8 +145,9 @@ export interface MergeOptions {
|
|
|
143
145
|
warn?: (message: string) => void;
|
|
144
146
|
/**
|
|
145
147
|
* Refuse instead of warn: when the merged file differs from the original
|
|
146
|
-
* where the author made no edit,
|
|
147
|
-
*
|
|
148
|
+
* where the author made no edit, or (with sidecars, `mergeFlowWithSidecars`)
|
|
149
|
+
* would leave out an agent sidecar edit, throw {@link MergeRefusedError}
|
|
150
|
+
* listing every such finding, and return nothing. The other findings still go to `warn`.
|
|
148
151
|
* Two findings never refuse, because the merged file does what the original
|
|
149
152
|
* does: an End the compile appends to a tail the original leaves open, and a
|
|
150
153
|
* binding row the round trip drops (merge keeps the original's). Meant to be
|
|
@@ -162,3 +165,15 @@ export interface MergeOptions {
|
|
|
162
165
|
*/
|
|
163
166
|
export declare function nodesTakenFromEdited(original: AnyFlow, edited: AnyFlow, baseline?: AnyFlow): string[];
|
|
164
167
|
export declare function mergeFlow(original: AnyFlow, edited: AnyFlow, options?: MergeOptions): AnyFlow;
|
|
168
|
+
/**
|
|
169
|
+
* `mergeFlow`, plus the agent sidecar files the merged flow needs written beside
|
|
170
|
+
* it (#1207). An inline agent's prompts and settings live only in
|
|
171
|
+
* `<source>/agent.json`, so an edit to them changes no node; this carries the
|
|
172
|
+
* author's change to that file onto the original's copy. What it cannot carry
|
|
173
|
+
* becomes a finding: warned, and refused under `strict`, with the flow's own
|
|
174
|
+
* findings. See merge-sidecars.ts.
|
|
175
|
+
*/
|
|
176
|
+
export declare function mergeFlowWithSidecars(original: AnyFlow, edited: AnyFlow, sidecars: MergeSidecarSources, options?: MergeOptions): {
|
|
177
|
+
flow: AnyFlow;
|
|
178
|
+
sidecars: SidecarWrite[];
|
|
179
|
+
};
|
package/dist/merge.js
CHANGED
|
@@ -79,6 +79,7 @@ import { jsonClone } from './core/json-file.js';
|
|
|
79
79
|
import { DEFINITIONLESS_NODE_TYPES } from './core/definitionless-node-types.js';
|
|
80
80
|
import { MergeRefusedError, mergedFileFindings, roundTripFindings } from './merge-roundtrip.js';
|
|
81
81
|
import { placeAddedNodes } from './node-placement.js';
|
|
82
|
+
import { mergeAgentSidecars } from './merge-sidecars.js';
|
|
82
83
|
export { MergeRefusedError } from './merge-roundtrip.js';
|
|
83
84
|
function byId(nodes) {
|
|
84
85
|
return new Map((nodes ?? []).map((n) => [n.id, n]));
|
|
@@ -145,6 +146,34 @@ function layoutNodesOf(f) {
|
|
|
145
146
|
return nodes && typeof nodes === 'object' && !Array.isArray(nodes) ? nodes : undefined;
|
|
146
147
|
}
|
|
147
148
|
export function mergeFlow(original, edited, options = {}) {
|
|
149
|
+
const { result, findings } = mergeFlowFindings(original, edited, options);
|
|
150
|
+
reportOrRefuse(findings, options);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* `mergeFlow`, plus the agent sidecar files the merged flow needs written beside
|
|
155
|
+
* it (#1207). An inline agent's prompts and settings live only in
|
|
156
|
+
* `<source>/agent.json`, so an edit to them changes no node; this carries the
|
|
157
|
+
* author's change to that file onto the original's copy. What it cannot carry
|
|
158
|
+
* becomes a finding: warned, and refused under `strict`, with the flow's own
|
|
159
|
+
* findings. See merge-sidecars.ts.
|
|
160
|
+
*/
|
|
161
|
+
export function mergeFlowWithSidecars(original, edited, sidecars, options = {}) {
|
|
162
|
+
const { result, findings } = mergeFlowFindings(original, edited, options);
|
|
163
|
+
const carried = mergeAgentSidecars(result, sidecars);
|
|
164
|
+
reportOrRefuse([...findings, ...carried.findings], options);
|
|
165
|
+
return { flow: result, sidecars: carried.writes };
|
|
166
|
+
}
|
|
167
|
+
/** Warn every finding; under `strict`, throw over the refusing ones (and still warn the rest). */
|
|
168
|
+
function reportOrRefuse(findings, options) {
|
|
169
|
+
const refusing = options.strict ? findings.filter((f) => f.refuses) : [];
|
|
170
|
+
for (const f of findings)
|
|
171
|
+
if (!refusing.includes(f))
|
|
172
|
+
options.warn?.(f.message);
|
|
173
|
+
if (refusing.length > 0)
|
|
174
|
+
throw new MergeRefusedError(refusing);
|
|
175
|
+
}
|
|
176
|
+
function mergeFlowFindings(original, edited, options) {
|
|
148
177
|
const result = jsonClone(original);
|
|
149
178
|
const origNodes = byId(original.nodes);
|
|
150
179
|
const baseNodes = byId(options.baseline?.nodes);
|
|
@@ -219,19 +248,12 @@ export function mergeFlow(original, edited, options = {}) {
|
|
|
219
248
|
result.version = edited.version;
|
|
220
249
|
}
|
|
221
250
|
// ── What the no-edit round trip changed, and what mixing sources broke. ──
|
|
222
|
-
//
|
|
223
|
-
// refused run shows them too.
|
|
251
|
+
// The caller warns them, or refuses under strict (`reportOrRefuse`).
|
|
224
252
|
const findings = [
|
|
225
253
|
...(options.baseline ? roundTripFindings(original, options.baseline, result) : []),
|
|
226
254
|
...mergedFileFindings(result, original),
|
|
227
255
|
];
|
|
228
|
-
|
|
229
|
-
for (const f of findings)
|
|
230
|
-
if (!refusing.includes(f))
|
|
231
|
-
options.warn?.(f.message);
|
|
232
|
-
if (refusing.length > 0)
|
|
233
|
-
throw new MergeRefusedError(refusing);
|
|
234
|
-
return result;
|
|
256
|
+
return { result, findings };
|
|
235
257
|
}
|
|
236
258
|
/**
|
|
237
259
|
* The root edges with their edited ids, except that an edge with no id, or with
|
|
@@ -6,6 +6,8 @@ import { hitlDerivesOutcomeHandles, outcomeIdOf } from '../../../core/hitl-routi
|
|
|
6
6
|
export const HITL_DIAGNOSTICS = {
|
|
7
7
|
HITL_VARIANT_UNKNOWN: { severity: 'error', summary: 'a variant that names no human-task node type' },
|
|
8
8
|
HITL_OUTCOME_PORTS_VARIANT: { severity: 'error', summary: 'exposeError or outcomePorts on a variant that cannot honour it' },
|
|
9
|
+
HITL_LEGACY_COMPLETED: { severity: 'warning', summary: "a task with outcomes that leaves on the legacy 'completed' handle" },
|
|
10
|
+
HITL_LEGACY_COMPLETED_CONFLICT: { severity: 'error', summary: 'legacyCompleted with outcomePorts or exposeError, or where it changes nothing' },
|
|
9
11
|
HITL_DOCUMENT_WITHOUT_VARIANT: { severity: 'error', summary: 'a document on a task that is not a validation station' },
|
|
10
12
|
HITL_DOCVAL_WITH_FIELDS: { severity: 'error', summary: 'form fields on a validation station' },
|
|
11
13
|
HITL_DOCVAL_NO_DOCUMENT: { severity: 'error', summary: 'a validation station with no document' },
|
|
@@ -86,6 +88,28 @@ export function checkHitl(inputs, ctx) {
|
|
|
86
88
|
: `Route on out('${step}', 'Action') downstream.`);
|
|
87
89
|
return;
|
|
88
90
|
}
|
|
91
|
+
// `legacyCompleted: true` keeps a pre-#3512 file's `completed` edge (#1197).
|
|
92
|
+
// It is the file's shape, so it compiles, but what it does at run time is
|
|
93
|
+
// rarely what the author wants, and `flow validate` says nothing about it.
|
|
94
|
+
if (inputs?.legacyCompleted === true) {
|
|
95
|
+
const outcomes = Array.isArray(inputs?.outcomes) ? inputs.outcomes : [];
|
|
96
|
+
if (inputs?.outcomePorts !== undefined || inputs?.exposeError === true) {
|
|
97
|
+
at('HITL_LEGACY_COMPLETED_CONFLICT', `Human task "${step}" sets legacyCompleted: true, which leaves it on its single 'completed' handle, and also `
|
|
98
|
+
+ `${inputs?.exposeError === true ? 'exposeError' : 'outcomePorts'}, which asks for per-outcome exits.`, 'Drop `legacyCompleted`.');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!hitlDerivesOutcomeHandles(variant) || outcomes.length === 0) {
|
|
102
|
+
at('HITL_LEGACY_COMPLETED_CONFLICT', `Human task "${step}" sets legacyCompleted: true, but ${outcomes.length === 0 ? 'it has no outcomes'
|
|
103
|
+
: `its variant is "${variant}", which reads its outcomes from its app`}, so 'completed' is already its only exit.`, 'Drop `legacyCompleted`.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const first = typeof outcomes[0] === 'string' ? outcomes[0] : outcomes[0]?.name;
|
|
107
|
+
at('HITL_LEGACY_COMPLETED', `Human task "${step}" leaves on the legacy 'completed' handle (legacyCompleted: true). Studio Web and the `
|
|
108
|
+
+ `runtime read that edge as the primary outcome, ${JSON.stringify(String(first ?? ''))}, only`
|
|
109
|
+
+ (outcomes.length > 1 ? ': when the reviewer picks any other outcome, the run stops at the task.' : '.'), outcomes.length > 1
|
|
110
|
+
? 'outcomePorts: false (every outcome continues to the next step), or outcomePorts: true / .stepSwitch (route each one)'
|
|
111
|
+
: 'Drop `legacyCompleted`: one outcome continues to the next step on its own handle.');
|
|
112
|
+
}
|
|
89
113
|
if (inputs?.document !== undefined && !isDocval) {
|
|
90
114
|
at('HITL_DOCUMENT_WITHOUT_VARIANT', `Human task "${step}" names a document to validate, but its variant is `
|
|
91
115
|
+ `${variant === undefined ? 'the default node type' : `"${variant}"`} — only the validation `
|
|
@@ -252,6 +252,12 @@ function hitlExits({ inputs }, pinnedVersion) {
|
|
|
252
252
|
if (model.shape === 'placeholder') {
|
|
253
253
|
// WHY it has no outcome exits decides what the author can do about it, so
|
|
254
254
|
// the caller gets the specific one rather than "ports are off".
|
|
255
|
+
if (model.legacyCompleted) {
|
|
256
|
+
return {
|
|
257
|
+
reason: "legacyCompleted: true leaves it on its single 'completed' exit",
|
|
258
|
+
suggestion: 'Drop `legacyCompleted` to route its outcomes.',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
255
261
|
if ((inputs?.outcomes ?? []).length === 0) {
|
|
256
262
|
return { reason: 'it declares no outcomes', suggestion: `outcomes: ['Approve', 'Reject']` };
|
|
257
263
|
}
|
|
@@ -413,24 +419,24 @@ function liftHitl(node, typeName, ctx) {
|
|
|
413
419
|
const outcomePorts = (typeName === BASE && version === '1.1')
|
|
414
420
|
|| (derivesHandles && !versionRoutes && !ctx.convergedFanOut()
|
|
415
421
|
&& outPorts.some((p) => p.startsWith('outcome-')));
|
|
416
|
-
// A
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// `
|
|
421
|
-
//
|
|
422
|
-
const legacyCompleted = derivesHandles && outcomeIdsOfNode(n).length >
|
|
423
|
-
|
|
424
|
-
// continues to the next step". Say so
|
|
425
|
-
//
|
|
426
|
-
// author dropping the pin.
|
|
427
|
-
const fanOut = !outcomePorts && derivesHandles && !versionRoutes
|
|
428
|
-
&& (
|
|
422
|
+
// A `completed` edge on a task with outcomes is the shape the designer
|
|
423
|
+
// stopped drawing: before flow-workbench #3512 (09-08) it was unconditioned,
|
|
424
|
+
// so every outcome continued down it; since then Studio Web and the converter
|
|
425
|
+
// remap it to the primary outcome only. The source keeps it as it is,
|
|
426
|
+
// `legacyCompleted: true`, so a round trip with no edits writes the same edge
|
|
427
|
+
// back (#1197) and the author decides what it should mean.
|
|
428
|
+
const legacyCompleted = derivesHandles && !versionRoutes && outcomeIdsOfNode(n).length > 0
|
|
429
|
+
&& outPorts.includes('completed');
|
|
430
|
+
// A converged fan-out means "every outcome continues to the next step". Say so
|
|
431
|
+
// in the source with `outcomePorts: false` rather than leaving it to the
|
|
432
|
+
// `{ version }` pin, so the intent survives an author dropping the pin.
|
|
433
|
+
const fanOut = !outcomePorts && !legacyCompleted && derivesHandles && !versionRoutes
|
|
434
|
+
&& outcomeIdsOfNode(n).length > 1 && ctx.convergedFanOut();
|
|
429
435
|
if (legacyCompleted) {
|
|
430
436
|
ctx.warn(`human task ${JSON.stringify(n.id)} leaves on the legacy 'completed' handle, which Studio Web no longer draws `
|
|
431
|
-
+ `once a task has outcomes
|
|
432
|
-
+ `
|
|
433
|
-
+ `
|
|
437
|
+
+ `once a task has outcomes: it runs that edge as the primary outcome only, so any other outcome stops the run. `
|
|
438
|
+
+ `The source keeps it (legacyCompleted: true), so recompiling writes the same edge. To continue every outcome, `
|
|
439
|
+
+ `replace it with outcomePorts: false; to route each one, outcomePorts: true or .stepSwitch.`);
|
|
434
440
|
}
|
|
435
441
|
const title = plain(i.title);
|
|
436
442
|
const priority = plain(i.priority);
|
|
@@ -447,7 +453,8 @@ function liftHitl(node, typeName, ctx) {
|
|
|
447
453
|
// there is serialize's own, not the author's.
|
|
448
454
|
...(fields.length || (variant !== 'action-app' && variant !== 'document-validation') ? { fields } : {}),
|
|
449
455
|
outcomes,
|
|
450
|
-
...(
|
|
456
|
+
...(legacyCompleted ? { legacyCompleted: true }
|
|
457
|
+
: outcomePorts ? { outcomePorts: true } : fanOut ? { outcomePorts: false } : {}),
|
|
451
458
|
...(version === '1.2' ? { exposeError: true } : {}),
|
|
452
459
|
};
|
|
453
460
|
return { kind: 'hitl', inputs: lifted };
|
|
@@ -511,6 +518,7 @@ function printHitl({ inputs }, ctx) {
|
|
|
511
518
|
['outcomes', `[${outcomes.join(', ')}]`],
|
|
512
519
|
['outcomePorts', h.outcomePorts === true ? 'true' : h.outcomePorts === false ? 'false' : undefined],
|
|
513
520
|
['exposeError', h.exposeError ? 'true' : undefined],
|
|
521
|
+
['legacyCompleted', h.legacyCompleted ? 'true' : undefined],
|
|
514
522
|
],
|
|
515
523
|
};
|
|
516
524
|
}
|
|
@@ -535,6 +543,14 @@ function hitlAt(typeName, version, factory) {
|
|
|
535
543
|
throw new Error(`"${ctx.step}": outcomePorts: true, but the '${inputs?.variant}' variant reads its outcomes `
|
|
536
544
|
+ `from its app and leaves on its single 'completed' exit. Route on out('${ctx.step}', 'Action') downstream.`);
|
|
537
545
|
}
|
|
546
|
+
if (inputs?.legacyCompleted === true && (inputs?.outcomePorts !== undefined || inputs?.exposeError === true)) {
|
|
547
|
+
throw new Error(`"${ctx.step}": legacyCompleted: true leaves the task on its single 'completed' handle, so it cannot also `
|
|
548
|
+
+ `${inputs?.exposeError === true ? 'expose the error output (exposeError)' : 'set outcomePorts'}. Drop one.`);
|
|
549
|
+
}
|
|
550
|
+
if (inputs?.legacyCompleted === true && typeName === BASE && (pinned === '1.1' || pinned === '1.2')) {
|
|
551
|
+
throw new Error(`"${ctx.step}": legacyCompleted: true, but { version: '${pinned}' } pins the task's per-outcome definition, `
|
|
552
|
+
+ `which has no 'completed' handle (flow validate refuses the edge). Drop the pin or legacyCompleted.`);
|
|
553
|
+
}
|
|
538
554
|
if (inputs?.exposeError === true && pinned !== undefined && pinned !== '1.2') {
|
|
539
555
|
throw new Error(`"${ctx.step}": exposeError needs the task's 1.2 definition, but { version: '${pinned}' } pins it. `
|
|
540
556
|
+ `Drop the pin or match it.`);
|
|
@@ -554,8 +570,9 @@ function hitlAt(typeName, version, factory) {
|
|
|
554
570
|
return { ...contribution, fanOut: exitModel.exits.map((e) => ({ port: e.port, ...(e.endsProcess ? { endsRun: true } : {}) })) };
|
|
555
571
|
}
|
|
556
572
|
// Every human-task definition declares one static source handle,
|
|
557
|
-
// `completed`: the real exit on an app-backed task and on one with no
|
|
558
|
-
|
|
573
|
+
// `completed`: the real exit on an app-backed task and on one with no
|
|
574
|
+
// outcomes, and the author's explicit choice with `legacyCompleted: true`.
|
|
575
|
+
return { ...contribution, port: 'completed', ...(exitModel.legacyCompleted ? { legacyCompleted: true } : {}) };
|
|
559
576
|
},
|
|
560
577
|
deserialize: (node, ctx) => liftHitl(node, typeName, ctx),
|
|
561
578
|
print: printHitl,
|
|
@@ -249,6 +249,39 @@ export interface HitlInputs {
|
|
|
249
249
|
* {@link HitlInputs.outcomePorts | outcomePorts}. Default node only.
|
|
250
250
|
*/
|
|
251
251
|
exposeError?: boolean;
|
|
252
|
+
/**
|
|
253
|
+
* Leave on the task's legacy `completed` handle, as a file saved before
|
|
254
|
+
* Studio Web drew one handle per outcome does. `decompile` writes this for
|
|
255
|
+
* such a file, so a round trip with no edits gives its `completed` edge back
|
|
256
|
+
* unchanged instead of rewriting it into one `outcome-<id>` edge per outcome.
|
|
257
|
+
*
|
|
258
|
+
* Not for new flows. Studio Web and the runtime read a `completed` edge on a
|
|
259
|
+
* task with outcomes as the PRIMARY (first) outcome only: when the reviewer
|
|
260
|
+
* picks any other outcome, that path has no edge and the run stops there.
|
|
261
|
+
* `check` warns (`HITL_LEGACY_COMPLETED`). To continue every outcome to the
|
|
262
|
+
* next step, drop this and set
|
|
263
|
+
* {@link HitlInputs.outcomePorts | outcomePorts}`: false`; to route each
|
|
264
|
+
* outcome, use `outcomePorts: true` or `.stepSwitch`.
|
|
265
|
+
*
|
|
266
|
+
* Only on the default node and `'quick-form'` with at least one outcome (the
|
|
267
|
+
* other variants always leave on `completed`), and not with `outcomePorts` or
|
|
268
|
+
* `exposeError`.
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```ts
|
|
272
|
+
* // Decompiled from a task whose one edge leaves on `completed`.
|
|
273
|
+
* export default flow('review')
|
|
274
|
+
* .step('review1', hitl({
|
|
275
|
+
* variant: 'quick-form',
|
|
276
|
+
* fields: [{ id: 'comments', type: 'text', direction: 'output' }],
|
|
277
|
+
* outcomes: ['Submit', 'Reject'],
|
|
278
|
+
* legacyCompleted: true,
|
|
279
|
+
* }))
|
|
280
|
+
* .step('approved1', script({ code: 'return 1;' }))
|
|
281
|
+
* .build();
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
legacyCompleted?: boolean;
|
|
252
285
|
/**
|
|
253
286
|
* The document to validate. Required by — and only legal with —
|
|
254
287
|
* `variant: 'document-validation'`.
|
package/dist/serialize.js
CHANGED
|
@@ -4423,6 +4423,13 @@ export function serialize(built, opts = {}) {
|
|
|
4423
4423
|
* `.stepToRef` rejoins now do come through here, #938).
|
|
4424
4424
|
*/
|
|
4425
4425
|
const hitlNodes = new Map();
|
|
4426
|
+
/**
|
|
4427
|
+
* Human tasks with outcomes that set `legacyCompleted: true`: they leave on
|
|
4428
|
+
* `completed` by the author's choice, so the guard exempts that one port
|
|
4429
|
+
* (flow-builder-sdk#1197). They are not tracked in `hitlNodes`, whose tails are
|
|
4430
|
+
* outcome edges; theirs is the one `completed` edge.
|
|
4431
|
+
*/
|
|
4432
|
+
const legacyCompletedNodes = new Set();
|
|
4426
4433
|
/** Step name → node id of a step whose every forward exit is templated (see `contributionPort`). */
|
|
4427
4434
|
const routerNodes = new Map();
|
|
4428
4435
|
/**
|
|
@@ -4436,7 +4443,7 @@ export function serialize(built, opts = {}) {
|
|
|
4436
4443
|
const hitlEndExits = [];
|
|
4437
4444
|
function addEdge(scope, from, toNode, toPort = 'input') {
|
|
4438
4445
|
const hitlNode = hitlNodes.get(from.node);
|
|
4439
|
-
const refused = hitlNode ? hitlExitViolation(hitlNode, from.port) : undefined;
|
|
4446
|
+
const refused = hitlNode ? hitlExitViolation(hitlNode, from.port, legacyCompletedNodes.has(from.node)) : undefined;
|
|
4440
4447
|
if (refused)
|
|
4441
4448
|
throw new Error(`serialize: ${refused}`);
|
|
4442
4449
|
scope.edges.push({ id: `edge_${edgeSeq++}`, sourceNodeId: from.node, sourcePort: from.port, targetNodeId: toNode, targetPort: toPort });
|
|
@@ -4612,7 +4619,7 @@ export function serialize(built, opts = {}) {
|
|
|
4612
4619
|
for (const vd of childGlobals.vars)
|
|
4613
4620
|
globals.push({ id: vd.name, name: vd.name, direction: 'inout', type: vd.type, ...varMeta(vd) });
|
|
4614
4621
|
resolveRejoins(scope, addEdge);
|
|
4615
|
-
assertHitlExitInvariant(scope.nodes, scope.edges);
|
|
4622
|
+
assertHitlExitInvariant(scope.nodes, scope.edges, legacyCompletedNodes);
|
|
4616
4623
|
typeAgentInputSchemas(scope, globals);
|
|
4617
4624
|
emitStickyNotes(child, scope, claimNoteId);
|
|
4618
4625
|
const layoutEdges = applyAuthorLayout(child.layout, scope, `subflow "${nodeId}"`);
|
|
@@ -4815,7 +4822,9 @@ export function serialize(built, opts = {}) {
|
|
|
4815
4822
|
// edge out of it is held to that rule (`hitlExitViolation`, in addEdge), and
|
|
4816
4823
|
// the step list treats its tails as outcome edges. A rawNode of the type is
|
|
4817
4824
|
// not one: it routes by the port lists its author wrote.
|
|
4818
|
-
if (o.trackOutcomes &&
|
|
4825
|
+
if (o.trackOutcomes && contribution.legacyCompleted)
|
|
4826
|
+
legacyCompletedNodes.add(id);
|
|
4827
|
+
else if (o.trackOutcomes && OUTCOME_HANDLE_NODE_TYPES.has(o.typeName))
|
|
4819
4828
|
hitlNodes.set(id, node);
|
|
4820
4829
|
for (const t of o.tails)
|
|
4821
4830
|
addEdge(scope, t, id);
|
|
@@ -5979,10 +5988,13 @@ export function serialize(built, opts = {}) {
|
|
|
5979
5988
|
* Refuse a side exit on a human task's port that the step's tails already
|
|
5980
5989
|
* continue from: that would put two edges on one port (flow-builder-sdk#741).
|
|
5981
5990
|
* After a fan-out task that is EVERY outcome port. `check` reports the same
|
|
5982
|
-
* as HITL_OUTCOME_PORTS_OFF; this is the compile-side half.
|
|
5991
|
+
* as HITL_OUTCOME_PORTS_OFF; this is the compile-side half. A
|
|
5992
|
+
* `legacyCompleted` task counts too: its main path leaves on `completed`, so a
|
|
5993
|
+
* side route there is the same second edge.
|
|
5983
5994
|
*/
|
|
5984
5995
|
function refuseRewire(method, port, source, tails) {
|
|
5985
|
-
|
|
5996
|
+
const node = tails[0]?.node ?? '';
|
|
5997
|
+
if (!(hitlNodes.has(node) || legacyCompletedNodes.has(node)) || !tails.some((t) => t.port === port))
|
|
5986
5998
|
return;
|
|
5987
5999
|
throw new Error(tails.length > 1
|
|
5988
6000
|
? `${method}('${port}', …) after human task "${source}": every outcome already continues to the next `
|
|
@@ -6641,7 +6653,7 @@ export function serialize(built, opts = {}) {
|
|
|
6641
6653
|
}
|
|
6642
6654
|
}
|
|
6643
6655
|
resolveRejoins(root, addEdge);
|
|
6644
|
-
assertHitlExitInvariant(root.nodes, root.edges);
|
|
6656
|
+
assertHitlExitInvariant(root.nodes, root.edges, legacyCompletedNodes);
|
|
6645
6657
|
typeAgentInputSchemas(root, globals);
|
|
6646
6658
|
emitStickyNotes(built, root, claimNoteId);
|
|
6647
6659
|
const rootLayoutEdges = applyAuthorLayout(built.layout, root, `flow "${built.id}"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uipath/maestro-builder-sdk",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.30.1",
|
|
4
4
|
"description": "Build UiPath Flow, Case, and BPMN artifacts by writing TypeScript.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://docs.uipath.com/maestro",
|
|
@@ -92,5 +92,5 @@
|
|
|
92
92
|
"@types/node": "^22.7.0",
|
|
93
93
|
"esbuild": "^0.28.1"
|
|
94
94
|
},
|
|
95
|
-
"gitref": "
|
|
95
|
+
"gitref": "07dd8e31058cf19c3db3a504b6e2bbb56cf980d2"
|
|
96
96
|
}
|