@uipath/maestro-builder-sdk 6.25.1 → 6.27.0

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/decompile.js CHANGED
@@ -83,6 +83,13 @@ let runDefs = new Map();
83
83
  * agent that has to edit the file next.
84
84
  */
85
85
  let runRawManifests = [];
86
+ /**
87
+ * The file's definitions for pinned pairs this SDK cannot resolve, by
88
+ * `<type>@<version>` → hoisted const name (#1128). Emitted once, in
89
+ * `.definitions([...])`, so compile resolves the pin against the file's own
90
+ * definition and the node keeps its version even when the author edits it.
91
+ */
92
+ let runCarried = new Map();
86
93
  let runStrict = false;
87
94
  /** `DecompileOptions.warn` for the current run, and the messages already sent to it (one line per finding). */
88
95
  let runWarn;
@@ -131,22 +138,21 @@ function verClause(node) {
131
138
  + `the file's definitions[] disagrees with the instance.`);
132
139
  }
133
140
  }
134
- // A pin the SDK cannot resolve: `serialize` resolves an explicit { version }
135
- // exactly (design §2.6) — a registered type against its registered versions,
136
- // any other type against the bundle. Keeping that pin would emit source
137
- // `compile` refuses (#1128: quick-form@1.6 from `node add`, @1.4 from
138
- // `migrate`, script@1.0 from a pre-migrate file), so the source omits it: the
139
- // node compiles at the SDK's default, and `merge --baseline` keeps the
140
- // UNTOUCHED original node, with its own version and definition, verbatim
141
- // (`nodeUnchanged` ignores typeVersion). Interim: a node the author EDITS is
142
- // re-emitted at the default — the root fix is for compile to accept the
143
- // definition the file carries.
141
+ // A pin the SDK cannot resolve (design §2.6: exact, against the registered
142
+ // or bundled versions) is kept when the file carries that pair's definition:
143
+ // `.definitions([...])` hands it to compile (#1128). With no definition to
144
+ // carry, keeping the pin would emit source `compile` refuses, so the source
145
+ // omits it: the node compiles at the SDK's default, and `merge --baseline`
146
+ // keeps the UNTOUCHED original node verbatim (`nodeUnchanged` ignores
147
+ // typeVersion).
144
148
  //
145
149
  // A type with no SDK-known version at all (a connector, a published-resource
146
150
  // `uipath.core.*` node, a rawNode) resolves its version elsewhere, so its pin
147
151
  // is kept.
152
+ if (runCarried.has(`${node.type}@${tv}`))
153
+ return `version: ${tsLiteral(tv)}`;
148
154
  const registered = FLOW_NODE_TYPES.byTypeName(node.type);
149
- const shipped = registered ? Object.keys(registered.type.versions) : bundledVersions(node.type);
155
+ const shipped = shippedVersions(node.type);
150
156
  if (shipped.length > 0 && !shipped.includes(tv)) {
151
157
  const known = `${node.type}@${shipped.join(', ')}`;
152
158
  if (runStrict) {
@@ -161,6 +167,15 @@ function verClause(node) {
161
167
  }
162
168
  return `version: ${tsLiteral(tv)}`;
163
169
  }
170
+ /**
171
+ * The versions this SDK resolves a `{ version }` of `type` against: a
172
+ * registered type's registered versions, any other type's bundled ones; `[]`
173
+ * for a type it ships at no version (a connector, a published resource).
174
+ */
175
+ function shippedVersions(type) {
176
+ const registered = FLOW_NODE_TYPES.byTypeName(type);
177
+ return registered ? Object.keys(registered.type.versions) : bundledVersions(type);
178
+ }
164
179
  function verArg(node) {
165
180
  const clause = verClause(node);
166
181
  return clause ? `, { ${clause} }` : '';
@@ -1494,6 +1509,92 @@ function unsupportedSpec(node, imp, o) {
1494
1509
  // is a safe structural stand-in, and the marker records what it replaced.
1495
1510
  return `${imp.use('mock')}() /* TODO: unsupported node type ${commentText(node.type)} */`;
1496
1511
  }
1512
+ /**
1513
+ * Every pinned `<type>@<version>` in the file (root and subflows) that this SDK
1514
+ * cannot resolve, for a type it ships at some version, and that the file's
1515
+ * `definitions[]` holds — mapped to the const name decompile hoists the
1516
+ * definition under. For instance a quick-form node at 1.6 maps
1517
+ * `uipath.human-in-the-loop.quick-form@1.6` → `quickFormV1_6Definition`.
1518
+ */
1519
+ function carriedPairs(flow) {
1520
+ const out = new Map();
1521
+ const taken = new Set();
1522
+ const scopes = [flow.nodes ?? [], ...Object.values(flow.subflows ?? {}).map((sf) => sf?.nodes ?? [])];
1523
+ for (const n of scopes.flat()) {
1524
+ const key = `${n.type}@${n.typeVersion}`;
1525
+ if (!n.typeVersion || out.has(key) || !runDefs.has(key))
1526
+ continue;
1527
+ const shipped = shippedVersions(n.type);
1528
+ if (shipped.length === 0 || shipped.includes(n.typeVersion))
1529
+ continue;
1530
+ const base = `${camel(String(n.type).split('.').pop() ?? 'node')}V${String(n.typeVersion).replace(/[^A-Za-z0-9]+/g, '_')}Definition`;
1531
+ let name = base;
1532
+ for (let i = 2; taken.has(name); i++)
1533
+ name = `${base}${i}`;
1534
+ taken.add(name);
1535
+ out.set(key, name);
1536
+ warnCarriedInputDrift(n, runDefs.get(key));
1537
+ }
1538
+ return out;
1539
+ }
1540
+ /**
1541
+ * The input names a definition declares: its `inputDefaults` keys and the
1542
+ * top-level `inputs.<name>` its form fields edit. For instance quick-form@1.1
1543
+ * gives `recipient, schema`.
1544
+ */
1545
+ function definitionInputNames(def) {
1546
+ const names = new Set(Object.keys(def?.inputDefaults ?? {}));
1547
+ const visit = (v) => {
1548
+ if (Array.isArray(v)) {
1549
+ v.forEach(visit);
1550
+ return;
1551
+ }
1552
+ if (!v || typeof v !== 'object')
1553
+ return;
1554
+ const field = v.name;
1555
+ if (typeof field === 'string' && field.startsWith('inputs.'))
1556
+ names.add(field.slice('inputs.'.length).split(/[.[]/)[0]);
1557
+ for (const child of Object.values(v))
1558
+ visit(child);
1559
+ };
1560
+ visit(def?.form);
1561
+ return [...names].sort();
1562
+ }
1563
+ /** Compare two dotted versions numerically: `1.10` sorts after `1.9`. */
1564
+ function compareVersions(a, b) {
1565
+ const pa = a.split('.').map(Number);
1566
+ const pb = b.split('.').map(Number);
1567
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1568
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1569
+ if (d !== 0)
1570
+ return d;
1571
+ }
1572
+ return 0;
1573
+ }
1574
+ /**
1575
+ * Warn when a carried definition's inputs differ from the nearest version the
1576
+ * SDK ships (the highest one below the carried version, else the lowest). An
1577
+ * edited node keeps the file's pin, but its inputs are rebuilt in the SDK's
1578
+ * shape, so a renamed input would be written under the wrong name. For
1579
+ * instance `node "review1" keeps quick-form@1.6 ...; its inputs differ from
1580
+ * 1.1 (assignee)`.
1581
+ */
1582
+ function warnCarriedInputDrift(node, carried) {
1583
+ const versions = shippedVersions(node.type).filter((v) => BUNDLED_DEFINITIONS[`${node.type}@${v}`]).sort(compareVersions);
1584
+ const below = versions.filter((v) => compareVersions(v, String(node.typeVersion)) < 0);
1585
+ const nearest = below.length > 0 ? below[below.length - 1] : versions[0];
1586
+ if (nearest === undefined)
1587
+ return;
1588
+ const shipped = BUNDLED_DEFINITIONS[`${node.type}@${nearest}`];
1589
+ const factoryVersion = nearest;
1590
+ const a = definitionInputNames(carried);
1591
+ const b = definitionInputNames(shipped);
1592
+ const differ = [...a.filter((x) => !b.includes(x)), ...b.filter((x) => !a.includes(x))].sort();
1593
+ if (differ.length === 0)
1594
+ return;
1595
+ warnOnce(`node ${tsLiteral(node.id)} keeps ${node.type}@${node.typeVersion} from the file's definitions[]; its inputs differ from `
1596
+ + `${factoryVersion} (${differ.join(', ')}), so an edit to it may need rawNode.`);
1597
+ }
1497
1598
  /** `some-node.id` → `someNodeId`, for a hoisted const name. */
1498
1599
  function camel(id) {
1499
1600
  const parts = String(id).split(/[^A-Za-z0-9]+/).filter(Boolean);
@@ -2528,6 +2629,11 @@ function parseSwitchCase(expr) {
2528
2629
  * expression is `<discriminant> === <JSON-literal>`; the discriminant (shared by
2529
2630
  * all cases) becomes `on`, and each RHS literal a case `value`. The case port is
2530
2631
  * `case-<caseId>`; the optional default arm is the `default` port.
2632
+ *
2633
+ * A switch whose cases do NOT share that shape — the product's per-case form,
2634
+ * `amount > 100` / `amount <= 100`, first truthy case wins — decompiles to
2635
+ * `when:` cases with `on` left `undefined`, so each condition round-trips
2636
+ * as written (#1131).
2531
2637
  */
2532
2638
  function emitSwitch(node, ipdom, scope, ctx, stop, refInto) {
2533
2639
  const r = ipdom.get(node.id) ?? stop;
@@ -2535,70 +2641,55 @@ function emitSwitch(node, ipdom, scope, ctx, stop, refInto) {
2535
2641
  let caseIndex = 0;
2536
2642
  let disc;
2537
2643
  let nonUniform = false; // a case whose expr isn't `<disc> === <literal>`, or a differing discriminant
2538
- const caseSrcs = [];
2539
- for (const c of cases) {
2540
- const parsed = parseSwitchCase(String(c.expression ?? ''));
2541
- let value = '0';
2542
- let valueLiteral = '';
2543
- if (parsed) {
2644
+ // First pass: is every case one shared `<disc> === <literal>`? If not, every
2645
+ // case is emitted as `when:` with its expression as written.
2646
+ const parsed = cases.map((c) => {
2647
+ const p = parseSwitchCase(String(c.expression ?? ''));
2648
+ if (p) {
2544
2649
  if (disc === undefined)
2545
- disc = parsed.lhs;
2546
- else if (disc !== parsed.lhs)
2650
+ disc = p.lhs;
2651
+ else if (disc !== p.lhs)
2547
2652
  nonUniform = true;
2548
- valueLiteral = parsed.literal;
2549
- value = parsed.value;
2550
2653
  }
2551
2654
  else {
2552
2655
  nonUniform = true;
2553
- // Keep the best-effort output the TODO comment points at.
2554
- const m = String(c.expression ?? '').match(/^(.*?)\s*===?\s*(.*)$/);
2555
- if (m && m[1].trim() && disc === undefined)
2556
- disc = m[1].trim();
2557
- if (m) {
2558
- valueLiteral = m[2].trim();
2559
- try {
2560
- value = tsLiteral(JSON.parse(valueLiteral));
2561
- }
2562
- catch {
2563
- const q = valueLiteral.match(/^'(.*)'$/);
2564
- value = q ? tsLiteral(q[1]) : tsLiteral(valueLiteral);
2565
- }
2566
- }
2567
2656
  }
2657
+ return { c, p };
2658
+ });
2659
+ const caseSrcs = parsed.map(({ c, p }) => {
2568
2660
  const armSegs = forkRegion(portEdges(ctx.graph, node.id, `case-${c.id}`), r, scope, ipdom, ctx, refInto);
2569
- const defaultLabel = `Case ${valueLiteral}`;
2661
+ caseIndex += 1;
2662
+ const defaultLabel = nonUniform || !p ? `Case ${caseIndex}` : `Case ${p.literal}`;
2570
2663
  const labelPart = c.label && c.label !== defaultLabel ? `, label: ${tsLiteral(c.label)}` : '';
2571
2664
  // The file's case id comes back when it is not the positional one serialize
2572
2665
  // derives (a designer-drawn switch uses `default-1`, …), so the recompiled
2573
2666
  // edges leave from the ORIGINAL node's handles, which merge keeps (#1130).
2574
- caseIndex += 1;
2575
2667
  const idPart = typeof c.id === 'string' && c.id !== '' && c.id !== `case_${caseIndex}` ? `, id: ${tsLiteral(c.id)}` : '';
2576
- caseSrcs.push(`{ value: ${value}${labelPart}${idPart}, body: ${armCb('b', armSegs)} }`);
2668
+ const head = nonUniform || !p
2669
+ ? `when: ${emitBareExpr(String(c.expression ?? ''), ctx.imp, ctx.inputNames)}`
2670
+ : `value: ${p.value}`;
2671
+ return `{ ${head}${labelPart}${idPart}, body: ${armCb('b', armSegs)} }`;
2672
+ });
2673
+ // A case with no condition has no `when:` that compiles: emitting `js\`\``
2674
+ // would fail at compile ([EXPR_SYNTAX]) with no warning here.
2675
+ const empty = cases.filter((c) => String(c.expression ?? '').trim() === '');
2676
+ if (empty.length > 0) {
2677
+ const listing = empty.map((c) => tsLiteral(c.label ?? c.id)).join(', ');
2678
+ const message = `switch ${tsLiteral(node.id)} has a case with no condition (${listing}). `
2679
+ + '`.switch()` needs a condition on every case, so the decompiled `when:` is empty and recompiling fails. '
2680
+ + 'Give the case a condition in the .flow, or edit this switch in the .flow JSON directly.';
2681
+ if (runStrict)
2682
+ throw new Error(`decompile: ${message}`);
2683
+ warnOnce(message);
2577
2684
  }
2578
- const on = emitBareExpr(disc ?? '', ctx.imp, ctx.inputNames);
2685
+ const on = nonUniform ? 'undefined' : emitBareExpr(disc ?? '', ctx.imp, ctx.inputNames);
2579
2686
  const hasDefault = (node.inputs ?? {}).hasDefault === true || portEdge(ctx.graph, node.id, 'default') !== undefined;
2580
2687
  const defArm = hasDefault
2581
2688
  ? `, ${armCb('d', forkRegion(portEdges(ctx.graph, node.id, 'default'), r, scope, ipdom, ctx, refInto))}`
2582
2689
  : '';
2583
2690
  const caseList = `[\n${caseSrcs.map((c) => reindent(c, ' ')).join(',\n')},\n]`;
2584
- // The builder's `.switch(on, [{value}])` models `on === value` only. A flow whose
2585
- // cases carry arbitrary per-case boolean expressions (or a non-uniform
2586
- // discriminant) cannot round-trip through it — flag it rather than emit silently
2587
- // wrong values.
2588
- if (nonUniform) {
2589
- const listing = cases.map((c) => `${tsLiteral(c.label ?? c.id)}: ${tsLiteral(String(c.expression ?? ''))}`).join(', ');
2590
- const message = `switch ${tsLiteral(node.id)} has per-case conditions that are not one shared \`<discriminant> === <value>\` `
2591
- + `(${listing}). \`.switch()\` models only that form, so the decompiled \`on\`/\`value\` are placeholders: `
2592
- + 'recompiling either fails or changes which case runs. Edit this switch in the .flow JSON directly.';
2593
- if (runStrict)
2594
- throw new Error(`decompile: ${message}`);
2595
- warnOnce(message);
2596
- }
2597
- const warn = nonUniform
2598
- ? `\n/* TODO: this switch's case expressions are not a uniform \`<discriminant> === <value>\`; the recovered on/value are best-effort and need review. */`
2599
- : '';
2600
2691
  const ver = verArg(node);
2601
- return `.switch(${tsLiteral(node.id)}, ${on}, ${caseList}${defArm}${ver && !defArm ? ', undefined' : ''}${ver})${warn}`;
2692
+ return `.switch(${tsLiteral(node.id)}, ${on}, ${caseList}${defArm}${ver && !defArm ? ', undefined' : ''}${ver})`;
2602
2693
  }
2603
2694
  /**
2604
2695
  * Recover `.stickyNotes([...])` from the scope's `stickyNote` nodes.
@@ -2923,6 +3014,7 @@ export function decompile(flow, options = {}) {
2923
3014
  if (!runDefs.has(d.nodeType))
2924
3015
  runDefs.set(d.nodeType, d);
2925
3016
  }
3017
+ runCarried = carriedPairs(flow);
2926
3018
  const imp = new Imports();
2927
3019
  const graph = new Graph(flow.nodes ?? [], flow.edges ?? []);
2928
3020
  const globals = readFlowGlobals(flow.variables, runStrict);
@@ -2956,6 +3048,8 @@ export function decompile(flow, options = {}) {
2956
3048
  if (typeof flow.description === 'string') {
2957
3049
  e.line(d, `.description(${tsLiteral(flow.description)})`);
2958
3050
  }
3051
+ if (runCarried.size > 0)
3052
+ e.line(d, `.definitions([${[...runCarried.values()].join(', ')}])`);
2959
3053
  // Trigger nodes. One root is the default: the isDefaultEntryPoint carrier
2960
3054
  // when the file marks one (a multi-root flow always does), else `start`.
2961
3055
  // A node's TYPE is not enough: `uipath.connector.event.<key>.<event>` is a
@@ -3042,7 +3136,8 @@ export function decompile(flow, options = {}) {
3042
3136
  const lay = layoutSeg(flow, (flow.nodes ?? []).filter((n) => !detachedIds.has(n.id)), (flow.edges ?? []).filter((edge) => !detachedIds.has(edge.sourceNodeId) && !detachedIds.has(edge.targetNodeId)));
3043
3137
  const layout = lay ? reindent(lay, ' ') : '';
3044
3138
  const chain = [header, eps, body, sticky, layout, ' .build();'].filter((s) => s.length > 0).join('\n');
3045
- const hoisted = runRawManifests
3139
+ const carriedConsts = [...runCarried].map(([key, name]) => [name, JSON.stringify(runDefs.get(key), null, 2)]);
3140
+ const hoisted = [...carriedConsts, ...runRawManifests]
3046
3141
  .map(([name, json]) => `const ${name} = ${json};`)
3047
3142
  .join('\n\n');
3048
3143
  return [imp.line(importSpecifier), hoisted, chain].filter((p) => p.length > 0).join('\n\n') + '\n';
@@ -147,7 +147,12 @@ function walk(steps, roots, out) {
147
147
  walk(s.otherwise, roots, out);
148
148
  break;
149
149
  case 'switch': {
150
- pushExpr(s.on, s.name, roots, out);
150
+ if (s.on)
151
+ pushExpr(s.on, s.name, roots, out);
152
+ // A `when` case's condition is read like a branch condition (#1131).
153
+ for (const c of s.cases)
154
+ if (c.when)
155
+ pushExpr(c.when, s.name, roots, out);
151
156
  // Inside a case (or default) body the discriminant is bound to
152
157
  // `$vars.value`, so it is a valid root only there.
153
158
  const caseRoots = new Set(roots).add('value');
@@ -97,7 +97,12 @@ export interface FlowSerializeContext {
97
97
  bind(entry: {
98
98
  readonly id: string;
99
99
  } & Readonly<Record<string, unknown>>): void;
100
- /** The bundled definition `<typeName>@<version>`, or undefined when the SDK does not ship it. */
100
+ /**
101
+ * The bundled definition `<typeName>@<version>`, else one the flow carries;
102
+ * undefined when neither exists. When the node is emitted at a carried
103
+ * version, asking for its own type answers with that carried definition at
104
+ * any `version`, so a type's defaults follow the file's definition.
105
+ */
101
106
  definition(typeName: string, version: string): unknown;
102
107
  /**
103
108
  * The flow's bindings so far, by id — read-only. For a type that SHARES a
@@ -186,8 +191,8 @@ export interface FlowCheckContext {
186
191
  /** The version the node will be emitted at. */
187
192
  readonly version: string;
188
193
  /**
189
- * The SDK's bundled definition of this step's node type at `version` (the one
190
- * serialize emits from), or undefined when the SDK does not ship it.
194
+ * The definition serialize emits this step from: the one the flow carries at
195
+ * a carried version, else the SDK's bundled one; undefined when neither exists.
191
196
  */
192
197
  definition(): unknown;
193
198
  /** Report a diagnostic on this step. Its severity is the family catalog's for `code`. */
@@ -338,9 +338,33 @@ export declare function conversationTrigger(): TriggerSpec;
338
338
  export declare function voiceTrigger(): TriggerSpec;
339
339
  /** A value a `.switch` case compares the discriminant against. */
340
340
  export type CaseValue = string | number | boolean;
341
+ /**
342
+ * One case of `.switch(name, on, cases)`: either `{ value }`, taken when
343
+ * `on === value`, or `{ when }`, taken when its own condition is true (the
344
+ * product switch's per-case form, #1131). Cases are tried top to bottom and the
345
+ * first match wins.
346
+ */
347
+ export type SwitchCase = ({
348
+ value: CaseValue;
349
+ when?: undefined;
350
+ } | {
351
+ when: Expr;
352
+ value?: undefined;
353
+ }) & {
354
+ body: (b: ArmBuilder) => void;
355
+ label?: string;
356
+ id?: string;
357
+ };
341
358
  /** One arm of a built `.switch` (the serializer's view: body already collected). */
342
359
  export interface SwitchArm {
343
- value: CaseValue;
360
+ /** The value `on` is compared against (`on === value`). Absent on a `when` case. */
361
+ value?: CaseValue;
362
+ /**
363
+ * The case's own boolean condition, instead of `on === value` — the product
364
+ * switch stores one condition per case and takes the first that is true
365
+ * (#1131). Exactly one of `value` / `when`.
366
+ */
367
+ when?: Expr;
344
368
  label?: string;
345
369
  /**
346
370
  * The case's wire id — `inputs.cases[].id`, and the `case-<id>` port its arm
@@ -500,7 +524,7 @@ export type Step = {
500
524
  kind: 'switch';
501
525
  name: string;
502
526
  label?: string;
503
- on: Expr;
527
+ on?: Expr;
504
528
  cases: SwitchArm[];
505
529
  default?: Step[];
506
530
  options?: NodeOptions;
@@ -731,6 +755,12 @@ export interface BuiltFlow {
731
755
  layout?: FlowLayout;
732
756
  /** Canvas sticky notes from `.stickyNotes()`. Inert annotations. */
733
757
  stickyNotes?: StickyNote[];
758
+ /**
759
+ * Node definitions the flow carries, from `.definitions()`: exact manifests a
760
+ * pinned `{ version }` resolves against when the SDK does not bundle that
761
+ * version.
762
+ */
763
+ definitions?: Record<string, unknown>[];
734
764
  /** Additional roots from `.entryPoint()`. The default root stays `.trigger()`/`.input()`. */
735
765
  entryPoints?: BuiltEntryPoint[];
736
766
  /**
@@ -978,23 +1008,30 @@ declare class StepList {
978
1008
  * ], (other) => other.step('unknown', script({ code: 'return "Unknown";' })))
979
1009
  * ```
980
1010
  *
1011
+ * @example
1012
+ * **Cases with their own conditions** (`on` is `undefined`)
1013
+ * ```ts
1014
+ * .switch('route', undefined, [
1015
+ * { when: js`$vars.start.output.amount > 100`, label: 'High', body: (b) => b.step('review', script({ code: 'return 1;' })) },
1016
+ * { when: js`$vars.start.output.amount <= 100`, label: 'Low', body: (b) => b.step('auto', script({ code: 'return 2;' })) },
1017
+ * ])
1018
+ * ```
1019
+ *
981
1020
  * @param name - The switch node's id.
982
- * @param on - The value to compare each case against.
983
- * @param cases - One entry per arm, evaluated top to bottom; the first strict
984
- * match wins. `label` names the arm's outgoing edge. `id` keeps a case's wire
985
- * id (`inputs.cases[].id`, port `case-<id>`); omitted, it is `case_<n>`.
1021
+ * @param on - The value to compare each `value` case against; `undefined`
1022
+ * when every case carries its own `when` condition.
1023
+ * @param cases - One entry per arm, evaluated top to bottom; the first match
1024
+ * wins. A case is `{ value }` (strict `on === value`) or `{ when }` (its own
1025
+ * boolean expression). `label` names the arm's outgoing edge. `id` keeps a
1026
+ * case's wire id (`inputs.cases[].id`, port `case-<id>`); omitted, it is
1027
+ * `case_<n>`.
986
1028
  * @param defaultFn - Receives a sub-builder for the arm taken when nothing
987
1029
  * matches. Omit it and an unmatched value simply continues.
988
1030
  * @param options - Node options; `version` selects the exact definition
989
1031
  * version (see {@link NodeOptions}).
990
1032
  * @returns This builder, after every arm has rejoined.
991
1033
  */
992
- switch(name: string, on: Expr, cases: {
993
- value: CaseValue;
994
- body: (b: ArmBuilder) => void;
995
- label?: string;
996
- id?: string;
997
- }[], defaultFn?: (b: StepList) => void, options?: NodeOptions): this;
1034
+ switch(name: string, on: Expr | undefined, cases: SwitchCase[], defaultFn?: (b: StepList) => void, options?: NodeOptions): this;
998
1035
  /**
999
1036
  * Run two or more arms **in parallel** from this point and join them back
1000
1037
  * together on a Merge node — the one place the builder stops being a straight
@@ -1216,6 +1253,7 @@ declare class FlowBuilder extends StepList {
1216
1253
  private _stickyNotes?;
1217
1254
  private _version;
1218
1255
  private _schemaVersion?;
1256
+ private _definitions;
1219
1257
  private _inputs;
1220
1258
  private _outputs;
1221
1259
  private _vars;
@@ -1423,6 +1461,47 @@ declare class FlowBuilder extends StepList {
1423
1461
  * @defaultValue `'1.9'` — `FLOW_FORMAT_PROFILE.version`, the fleet write floor.
1424
1462
  */
1425
1463
  schemaVersion(version: string): this;
1464
+ /**
1465
+ * Carry node definitions this SDK does not bundle, so a `{ version }` pin on
1466
+ * one of those versions compiles.
1467
+ *
1468
+ * @remarks
1469
+ * A pinned `{ version }` resolves exactly (design §2.6): first against the
1470
+ * definitions the SDK was released with, then against the ones carried here.
1471
+ * Nothing else changes — an unpinned node still takes the SDK's default, and
1472
+ * a bundled version still compiles from the bundle.
1473
+ *
1474
+ * Its job is BROWNFIELD PRESERVATION. A `.flow` file holds the exact
1475
+ * definition of every node it uses, but the registry moves faster than an SDK
1476
+ * release: `uip maestro flow node add` writes
1477
+ * `uipath.human-in-the-loop.quick-form@1.6` while this SDK bundles 1.0 and
1478
+ * 1.1. `flow-decompile` copies the file's own entry into this call for each
1479
+ * such pair, so the decompiled source compiles unedited and a node you edit
1480
+ * keeps the file's version instead of moving to the SDK's.
1481
+ *
1482
+ * Each manifest must be an exact copy of one the platform served (from the
1483
+ * file's `definitions[]`, or `uip maestro flow registry get`): it names its
1484
+ * `nodeType` and `version`, and has a `display.label`. The SDK still emits
1485
+ * the node's inputs from its own factory, so a version whose inputs differ
1486
+ * from the SDK's shape needs `rawNode` instead.
1487
+ *
1488
+ * @example
1489
+ * **Keep a quick-form task at the version the file pins**
1490
+ * ```ts
1491
+ * // The .flow file's whole definitions[] entry for quick-form@1.6 (or
1492
+ * // `uip maestro flow registry get`), copied verbatim, as flow-decompile hoists it.
1493
+ * // A stub with only nodeType/version/display passes validation but breaks the canvas.
1494
+ * declare const quickFormV1_6Definition: Record<string, unknown>;
1495
+ * flow('review')
1496
+ * .definitions([quickFormV1_6Definition])
1497
+ * .step('review', hitl({ variant: 'quick-form', fields: [], outcomes: ['Submit'] }), { version: '1.6' });
1498
+ * ```
1499
+ *
1500
+ * @param manifests - Node manifests, each with its own `nodeType`, `version` and `display.label`.
1501
+ * @returns This builder, so calls chain.
1502
+ * @throws Error when a manifest is not a node manifest, or two differ for one `nodeType@version`.
1503
+ */
1504
+ definitions(manifests: readonly Record<string, unknown>[]): this;
1426
1505
  /**
1427
1506
  * Declare the flow's inputs. Read them with `input('<name>')`.
1428
1507
  *
package/dist/flow-sdk.js CHANGED
@@ -45,6 +45,7 @@ import { builtinActionSpec, builtinTriggerSpec, subscriptionFrom } from './core/
45
45
  import { checkFlowExpressions, FlowBuildError } from './flow-expr-check.js';
46
46
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
47
47
  import { schemaVersionRefusal } from './schema-version.js';
48
+ import { indexCarriedDefinitions } from './core/carried-definitions.js';
48
49
  export { FlowBuildError, checkFlowExpressions } from './flow-expr-check.js';
49
50
  const TYPE_NAMES = Object.values(types);
50
51
  /**
@@ -611,11 +612,23 @@ class StepList {
611
612
  * ], (other) => other.step('unknown', script({ code: 'return "Unknown";' })))
612
613
  * ```
613
614
  *
615
+ * @example
616
+ * **Cases with their own conditions** (`on` is `undefined`)
617
+ * ```ts
618
+ * .switch('route', undefined, [
619
+ * { when: js`$vars.start.output.amount > 100`, label: 'High', body: (b) => b.step('review', script({ code: 'return 1;' })) },
620
+ * { when: js`$vars.start.output.amount <= 100`, label: 'Low', body: (b) => b.step('auto', script({ code: 'return 2;' })) },
621
+ * ])
622
+ * ```
623
+ *
614
624
  * @param name - The switch node's id.
615
- * @param on - The value to compare each case against.
616
- * @param cases - One entry per arm, evaluated top to bottom; the first strict
617
- * match wins. `label` names the arm's outgoing edge. `id` keeps a case's wire
618
- * id (`inputs.cases[].id`, port `case-<id>`); omitted, it is `case_<n>`.
625
+ * @param on - The value to compare each `value` case against; `undefined`
626
+ * when every case carries its own `when` condition.
627
+ * @param cases - One entry per arm, evaluated top to bottom; the first match
628
+ * wins. A case is `{ value }` (strict `on === value`) or `{ when }` (its own
629
+ * boolean expression). `label` names the arm's outgoing edge. `id` keeps a
630
+ * case's wire id (`inputs.cases[].id`, port `case-<id>`); omitted, it is
631
+ * `case_<n>`.
619
632
  * @param defaultFn - Receives a sub-builder for the arm taken when nothing
620
633
  * matches. Omit it and an unmatched value simply continues.
621
634
  * @param options - Node options; `version` selects the exact definition
@@ -633,7 +646,14 @@ class StepList {
633
646
  // An arm's own `.label(...)` wins over the case config's `label`; either
634
647
  // overrides the serializer's derived `Case "<value>"`.
635
648
  const label = arm.armLabel ?? c.label;
636
- return { value: c.value, ...(label !== undefined ? { label } : {}), ...(c.id !== undefined ? { id: c.id } : {}), body: arm.steps };
649
+ return {
650
+ // Both kept as given, so check can refuse a case with both or neither.
651
+ ...(c.value !== undefined ? { value: c.value } : {}),
652
+ ...(c.when !== undefined ? { when: toExpr(c.when) } : {}),
653
+ ...(label !== undefined ? { label } : {}),
654
+ ...(c.id !== undefined ? { id: c.id } : {}),
655
+ body: arm.steps,
656
+ };
637
657
  });
638
658
  let dflt;
639
659
  if (defaultFn) {
@@ -648,7 +668,7 @@ class StepList {
648
668
  this.steps.push({
649
669
  kind: 'switch',
650
670
  name,
651
- on: toExpr(on),
671
+ ...(on !== undefined ? { on: toExpr(on) } : {}),
652
672
  cases: arms,
653
673
  ...(dflt !== undefined ? { default: dflt } : {}),
654
674
  ...(options ? { options } : {}),
@@ -881,6 +901,7 @@ class FlowBuilder extends StepList {
881
901
  _stickyNotes;
882
902
  _version = '1.0.0';
883
903
  _schemaVersion;
904
+ _definitions = [];
884
905
  _inputs = [];
885
906
  _outputs = [];
886
907
  _vars = [];
@@ -1144,6 +1165,52 @@ class FlowBuilder extends StepList {
1144
1165
  this._schemaVersion = version;
1145
1166
  return this;
1146
1167
  }
1168
+ /**
1169
+ * Carry node definitions this SDK does not bundle, so a `{ version }` pin on
1170
+ * one of those versions compiles.
1171
+ *
1172
+ * @remarks
1173
+ * A pinned `{ version }` resolves exactly (design §2.6): first against the
1174
+ * definitions the SDK was released with, then against the ones carried here.
1175
+ * Nothing else changes — an unpinned node still takes the SDK's default, and
1176
+ * a bundled version still compiles from the bundle.
1177
+ *
1178
+ * Its job is BROWNFIELD PRESERVATION. A `.flow` file holds the exact
1179
+ * definition of every node it uses, but the registry moves faster than an SDK
1180
+ * release: `uip maestro flow node add` writes
1181
+ * `uipath.human-in-the-loop.quick-form@1.6` while this SDK bundles 1.0 and
1182
+ * 1.1. `flow-decompile` copies the file's own entry into this call for each
1183
+ * such pair, so the decompiled source compiles unedited and a node you edit
1184
+ * keeps the file's version instead of moving to the SDK's.
1185
+ *
1186
+ * Each manifest must be an exact copy of one the platform served (from the
1187
+ * file's `definitions[]`, or `uip maestro flow registry get`): it names its
1188
+ * `nodeType` and `version`, and has a `display.label`. The SDK still emits
1189
+ * the node's inputs from its own factory, so a version whose inputs differ
1190
+ * from the SDK's shape needs `rawNode` instead.
1191
+ *
1192
+ * @example
1193
+ * **Keep a quick-form task at the version the file pins**
1194
+ * ```ts
1195
+ * // The .flow file's whole definitions[] entry for quick-form@1.6 (or
1196
+ * // `uip maestro flow registry get`), copied verbatim, as flow-decompile hoists it.
1197
+ * // A stub with only nodeType/version/display passes validation but breaks the canvas.
1198
+ * declare const quickFormV1_6Definition: Record<string, unknown>;
1199
+ * flow('review')
1200
+ * .definitions([quickFormV1_6Definition])
1201
+ * .step('review', hitl({ variant: 'quick-form', fields: [], outcomes: ['Submit'] }), { version: '1.6' });
1202
+ * ```
1203
+ *
1204
+ * @param manifests - Node manifests, each with its own `nodeType`, `version` and `display.label`.
1205
+ * @returns This builder, so calls chain.
1206
+ * @throws Error when a manifest is not a node manifest, or two differ for one `nodeType@version`.
1207
+ */
1208
+ definitions(manifests) {
1209
+ const known = indexCarriedDefinitions(this._definitions, 'definitions()');
1210
+ indexCarriedDefinitions(manifests, 'definitions()', known);
1211
+ this._definitions = [...known.values()];
1212
+ return this;
1213
+ }
1147
1214
  /**
1148
1215
  * Declare the flow's inputs. Read them with `input('<name>')`.
1149
1216
  *
@@ -1234,6 +1301,7 @@ class FlowBuilder extends StepList {
1234
1301
  ...(this._stickyNotes !== undefined ? { stickyNotes: this._stickyNotes } : {}),
1235
1302
  version: this._version,
1236
1303
  ...(this._schemaVersion !== undefined ? { schemaVersion: this._schemaVersion } : {}),
1304
+ ...(this._definitions.length ? { definitions: this._definitions } : {}),
1237
1305
  inputs: this._inputs,
1238
1306
  outputs: this._outputs,
1239
1307
  vars: this._vars,
package/dist/merge.js CHANGED
@@ -142,7 +142,8 @@ export function mergeFlow(original, edited, options = {}) {
142
142
  finalNodes.push(withOriginalLayout(jsonClone(e), orig, layoutNodesOf(original)));
143
143
  if (orig && orig.type === e.type && orig.typeVersion !== e.typeVersion) {
144
144
  options.warn?.(`node ${JSON.stringify(e.id)} moved ${e.type}@${String(orig.typeVersion)} -> ${String(e.typeVersion)} `
145
- + `(the edited node takes the SDK's version; flow-builder-sdk#1167).`);
145
+ + `(the edited source does not pin ${String(orig.typeVersion)}; keep the { version } pin, `
146
+ + `with .definitions([...]) when the SDK does not bundle that version).`);
146
147
  }
147
148
  }
148
149
  }