@uipath/maestro-builder-sdk 6.19.0 → 6.20.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.
Files changed (41) hide show
  1. package/dist/api-index.md +107 -107
  2. package/dist/api-members.md +358 -356
  3. package/dist/check.js +181 -45
  4. package/dist/cli/commands/flow.js +3 -0
  5. package/dist/compile-cli.js +51 -0
  6. package/dist/core/actions.d.ts +13 -5
  7. package/dist/core/actions.js +1 -1
  8. package/dist/core/binding-identity.d.ts +22 -0
  9. package/dist/core/binding-identity.js +28 -0
  10. package/dist/core/context-index.d.ts +34 -0
  11. package/dist/core/context-index.js +38 -0
  12. package/dist/core/definitionless-node-types.d.ts +16 -0
  13. package/dist/core/definitionless-node-types.js +16 -0
  14. package/dist/core/form-field-type.d.ts +33 -0
  15. package/dist/core/form-field-type.js +30 -0
  16. package/dist/core/hitl-routing.d.ts +8 -0
  17. package/dist/core/hitl-routing.js +17 -1
  18. package/dist/core/process-resource-sort-order.d.ts +40 -0
  19. package/dist/core/process-resource-sort-order.js +44 -0
  20. package/dist/core/wire-expr.d.ts +27 -0
  21. package/dist/core/wire-expr.js +34 -0
  22. package/dist/decompile-cli.js +11 -1
  23. package/dist/decompile.d.ts +8 -3
  24. package/dist/decompile.js +680 -104
  25. package/dist/flow-expr-check.js +1 -1
  26. package/dist/flow-sdk.d.ts +33 -1
  27. package/dist/flow-sdk.js +21 -2
  28. package/dist/flow-step-tree.js +2 -1
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.js +1 -0
  31. package/dist/merge-cli.js +25 -8
  32. package/dist/merge-roundtrip.d.ts +81 -0
  33. package/dist/merge-roundtrip.js +404 -0
  34. package/dist/merge.d.ts +35 -6
  35. package/dist/merge.js +124 -24
  36. package/dist/node-placement.d.ts +93 -0
  37. package/dist/node-placement.js +163 -0
  38. package/dist/nodes/transform/flow/node-type.js +2 -1
  39. package/dist/registry/uip/pipeline-front-door.js +5 -3
  40. package/dist/serialize.js +112 -61
  41. package/package.json +2 -2
package/dist/check.js CHANGED
@@ -10,6 +10,7 @@ import { FlowNode } from './core/node-classes.js';
10
10
  import { planContributionBindings } from './core/contribution-bindings.js';
11
11
  import { definitionHasErrorPort, errorPortRefusal } from './core/error-port.js';
12
12
  import { bundledActionNodeType, bundledDefinition, resolveContributionDefinition } from './serialize.js';
13
+ import { sdkFormFieldType } from './core/form-field-type.js';
13
14
  import { QUEUE_ITEM_RECORD_SCHEMA } from './core/queue-record.js';
14
15
  import { AGENTIC_PROCESS_FORMS } from './core/agentic-process-forms.js';
15
16
  import { checkEventResolution, checkEventSubscription, eventParamsFor, } from './core/event-checks.js';
@@ -19,7 +20,7 @@ import { prepareCommand } from './core/cli-spelling.js';
19
20
  import { commentText } from './core/comment-text.js';
20
21
  import { editDistance } from './core/edit-distance.js';
21
22
  import { isAnswerField } from './core/hitl-answers.js';
22
- import { hitlDerivesOutcomeHandles, hitlExitModel, hitlRoutesPerOutcome } from './core/hitl-routing.js';
23
+ import { hitlDerivesOutcomeHandles, hitlExitModel, hitlRoutesPerOutcome, outcomeIdOf } from './core/hitl-routing.js';
23
24
  import { declaredExits, hasExits } from './core/step-ports.js';
24
25
  import { stopsAtEnd } from './flow-step-tree.js';
25
26
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
@@ -179,6 +180,8 @@ function checkFlow(built, ancestors, opts = {}) {
179
180
  // OBJECT of the child's declared outputs, so a read of it is checked against
180
181
  // that child rather than waved through (see SUBFLOW_* below).
181
182
  const subflowChildren = new Map();
183
+ // `inLoop`: inside a loop/do-while body; `'arm'` when, counting from the
184
+ // innermost container, the path also sits in a `.parallel` arm.
182
185
  const walk = (steps, inLoop = false, errorSource) => {
183
186
  // Nothing may follow a terminate. `.return()` merely ends its own path, so a
184
187
  // later step there is at worst dead weight — but a terminate ends the RUN,
@@ -197,6 +200,22 @@ function checkFlow(built, ancestors, opts = {}) {
197
200
  step: term.name,
198
201
  });
199
202
  }
203
+ // Nothing may follow a `.break()` or `.continue()` on the same path: the
204
+ // path leaves through the container's handle, so a later step would get no
205
+ // incoming edge.
206
+ const exitAt = steps.findIndex((st) => st.kind === 'break' || st.kind === 'continue');
207
+ if (exitAt !== -1 && exitAt < steps.length - 1) {
208
+ const after = steps[exitAt + 1];
209
+ const kind = steps[exitAt].kind;
210
+ diags.push({
211
+ level: 'error',
212
+ code: 'LOOP_EXIT_UNREACHABLE_AFTER',
213
+ message: `${steps.length - exitAt - 1} step(s) follow .${kind}(), starting with `
214
+ + `"${'name' in after ? after.name : after.kind}". .${kind}() leaves this path through the container's `
215
+ + `${kind} handle, so none of them can ever run. Move them before the .${kind}().`,
216
+ ...('name' in after ? { step: String(after.name) } : {}),
217
+ });
218
+ }
200
219
  // Nothing may follow a human task whose EVERY outcome ends the run, except a
201
220
  // `.return()`. Each outcome gets its own End node — the flow runtime never
202
221
  // reads `action`, so only the graph can end the run there — which leaves
@@ -311,7 +330,7 @@ function checkFlow(built, ancestors, opts = {}) {
311
330
  }
312
331
  else {
313
332
  const model = hitlExitModel(prevSpec.inputs, prevAction?.options?.version);
314
- const ids = (prevSpec.inputs.outcomes ?? []).map((o, i) => outcomeSlug(typeof o === 'string' ? o : o?.name, i));
333
+ const ids = (prevSpec.inputs.outcomes ?? []).map((o, i) => outcomeIdOf(o, i));
315
334
  if (model.shape === 'placeholder') {
316
335
  diags.push({
317
336
  level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
@@ -424,7 +443,7 @@ function checkFlow(built, ancestors, opts = {}) {
424
443
  if (s0.kind !== 'stepSwitch'
425
444
  && s.spec.kind === 'hitl' && hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
426
445
  hitlOutcomeSteps.set(s.name, (s.spec.inputs.outcomes ?? []).map((o, i) => ({
427
- id: outcomeSlug(typeof o === 'string' ? o : o?.name, i),
446
+ id: outcomeIdOf(o, i),
428
447
  endsProcess: typeof o !== 'string' && o?.action === 'End',
429
448
  })));
430
449
  }
@@ -442,6 +461,7 @@ function checkFlow(built, ancestors, opts = {}) {
442
461
  && !hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
443
462
  hitlChoiceNoPorts.set(s.name, {
444
463
  names: outcomes.map((o) => String((typeof o === 'string' ? o : o?.name) ?? '').trim()),
464
+ ids: outcomes.map((o, i) => outcomeIdOf(o, i)),
445
465
  ends: outcomes.filter((o) => typeof o !== 'string' && o?.action === 'End')
446
466
  .map((o) => String(o.name ?? '').trim()),
447
467
  variant: s.spec.inputs?.variant,
@@ -563,6 +583,23 @@ function checkFlow(built, ancestors, opts = {}) {
563
583
  // Two arms with the same value: the second can never be reached, since
564
584
  // the runtime takes the first matching case.
565
585
  const seenValues = new Set();
586
+ // Two cases on one id share one `case-<id>` port (#1130: an explicit
587
+ // `id` can collide with a positional `case_<n>`).
588
+ const seenIds = new Set();
589
+ s.cases.forEach((c, i) => {
590
+ const caseId = c.id ?? `case_${i + 1}`;
591
+ if (typeof caseId !== 'string' || caseId === '' || seenIds.has(caseId)) {
592
+ diags.push({
593
+ level: 'error',
594
+ code: 'SWITCH_CASE_ID',
595
+ message: typeof caseId !== 'string' || caseId === ''
596
+ ? `Switch "${s.name}" case ${JSON.stringify(c.value)} has an id that is not a non-empty string. Leave \`id\` out for the positional case_<n>.`
597
+ : `Switch "${s.name}" has two cases with the id "${caseId}", so both would leave from one 'case-${caseId}' port. Give one a different \`id\`.`,
598
+ step: s.name,
599
+ });
600
+ }
601
+ seenIds.add(String(caseId));
602
+ });
566
603
  for (const c of s.cases) {
567
604
  const key = `${typeof c.value}:${String(c.value)}`;
568
605
  if (seenValues.has(key))
@@ -601,7 +638,7 @@ function checkFlow(built, ancestors, opts = {}) {
601
638
  parallelNames.add(s.name);
602
639
  checkParallel(s, diags);
603
640
  for (const arm of s.arms)
604
- walk(arm, inLoop);
641
+ walk(arm, inLoop ? 'arm' : false);
605
642
  }
606
643
  else if (s.kind === 'loop') {
607
644
  // A container is a legal ERROR-port source (its definition declares the
@@ -649,6 +686,17 @@ function checkFlow(built, ancestors, opts = {}) {
649
686
  });
650
687
  }
651
688
  }
689
+ else if (s.kind === 'continue') {
690
+ if (!inLoop) {
691
+ diags.push({
692
+ level: 'error', code: 'CONTINUE_OUTSIDE_LOOP',
693
+ message: '.continue() is only valid inside a .loop() or .doWhile() body: it goes on to the container\'s next iteration.',
694
+ });
695
+ }
696
+ else if (inLoop === 'arm') {
697
+ diags.push(loopExitInArm());
698
+ }
699
+ }
652
700
  else if (s.kind === 'return') {
653
701
  for (const [name, e] of Object.entries(s.values)) {
654
702
  addExpr(e.js, 'return');
@@ -1356,6 +1404,13 @@ function writtenTails(steps, incoming, sinks) {
1356
1404
  breaks.push(...tails);
1357
1405
  return [];
1358
1406
  }
1407
+ // A `.continue()` path goes to the container's next pass, not to the steps
1408
+ // after it: for a do-while that is the condition test, one of the ways out,
1409
+ // exactly like reaching the end of the body — so it joins the exits too.
1410
+ if (st.kind === 'continue') {
1411
+ breaks.push(...tails);
1412
+ return [];
1413
+ }
1359
1414
  if (st.kind === 'return' || st.kind === 'terminate')
1360
1415
  return [];
1361
1416
  const before = surelyWritten(tails);
@@ -2642,8 +2697,22 @@ function checkAgenticProcess(step, inputs, diags) {
2642
2697
  }
2643
2698
  const inSolution = inputs?.location === 'in-solution';
2644
2699
  if (!inSolution && blank(inputs?.folderPath)) {
2645
- at('AGENTIC_NO_FOLDER', `Agentic process step "${step}" has no \`folderPath\`. The runtime starts the job by name + folder, so `
2646
- + `the Orchestrator folder holding the process is required.`, `folderPath: 'Shared/uipath-agents/ProcurementProcess'`);
2700
+ // `registry list --local` shows an in-solution Flow sibling with folderPath: "".
2701
+ // An explicit `form` other than 'flow' keeps the published hint: switching it would change the node type.
2702
+ const meantInSolution = inputs?.form === 'flow'
2703
+ || (typeof inputs?.folderPath === 'string' && inputs?.form === undefined);
2704
+ if (meantInSolution) {
2705
+ const formPart = inputs?.form === 'flow' ? '' : `, form: 'flow'`;
2706
+ at('AGENTIC_NO_FOLDER', `Agentic process step "${step}" has an empty \`folderPath\`. If the process is an unpublished sibling `
2707
+ + `Flow in this solution, call it in-solution: set \`location: 'in-solution'\`${formPart} and \`projectId\` `
2708
+ + `(the sibling's \`resource.projectKey\` in \`resources/solution_folder/process/flow/<project>.json\`), and `
2709
+ + `drop \`folderPath\`. If it is published, set \`folderPath\` to the Orchestrator folder that holds it — `
2710
+ + `the runtime starts the job by name + folder.`, `location: 'in-solution'${formPart}, projectId: '<resource.projectKey>'`);
2711
+ }
2712
+ else {
2713
+ at('AGENTIC_NO_FOLDER', `Agentic process step "${step}" has no \`folderPath\`. The runtime starts the job by name + folder, so `
2714
+ + `the Orchestrator folder holding the process is required.`, `folderPath: 'Shared/uipath-agents/ProcurementProcess'`);
2715
+ }
2647
2716
  }
2648
2717
  const form = inputs?.form ?? 'bpmn';
2649
2718
  if (inSolution && form !== 'flow') {
@@ -3115,13 +3184,31 @@ function checkInlineAgent(step, inputs, diags, opts = {}) {
3115
3184
  + `run-dependent search needs.`);
3116
3185
  }
3117
3186
  }
3187
+ // The index id is half of the resource node's TYPE
3188
+ // (`…context.index.<slug>.<id>`), and the platform resolves the index by it.
3189
+ // A blank, padded or non-GUID id builds a type no tenant serves, and nothing
3190
+ // downstream says so: the flow compiles, merges and validates, and the agent
3191
+ // runs ungrounded. So it is an error, not a warning. The id is tested as
3192
+ // written, not trimmed, because serialize emits it as written.
3193
+ for (const cx of contexts) {
3194
+ const id = typeof cx?.id === 'string' ? cx.id : '';
3195
+ if (!GUID_RE.test(id)) {
3196
+ at('AGENT_CONTEXT_NO_ID', `Inline agent step "${step}" grounds on context index "${cx?.name ?? ''}" with `
3197
+ + (id.trim() === '' ? 'no `id`'
3198
+ : GUID_RE.test(id.trim()) ? `\`id\` ${JSON.stringify(cx?.id)}, which has whitespace around the GUID`
3199
+ : `\`id\` ${JSON.stringify(cx?.id)}, which is not a GUID`) + '. '
3200
+ + `The id is the index's key: it is the tail of the resource node's type and how the runtime finds `
3201
+ + `the index, so without a real one the agent runs ungrounded and \`validate\` does not notice. `
3202
+ + `\`uip solution resources list --kind Index --source remote --search "<name>"\` returns it as \`Key\`.`);
3203
+ }
3204
+ }
3118
3205
  // An index outside the deployment folder needs its folder named. The resource
3119
3206
  // file carries no `indexId`, so `indexName` + `folderPath` is the whole locator,
3120
3207
  // and `@uipath/tool-agent`'s `createIndexBinding` leaves the folder off the
3121
3208
  // binding when `folderPath` is empty. A WARNING, not an error: an index in the
3122
3209
  // deployment folder resolves without it.
3123
3210
  for (const cx of contexts) {
3124
- if (cx !== undefined && cx.folderPath === undefined) {
3211
+ if (cx != null && cx.folderPath === undefined) {
3125
3212
  warn('AGENT_CONTEXT_NO_FOLDER_PATH', `Inline agent step "${step}" grounds on context index "${cx.name ?? ''}" without a \`folderPath\`. `
3126
3213
  + `The emitted resource carries no index id, so the folder is half of how the runtime finds the `
3127
3214
  + `index, and the binding is written without one. Give the index's folder — `
@@ -3448,8 +3535,8 @@ function checkInlineAgent(step, inputs, diags, opts = {}) {
3448
3535
  for (const f of Array.isArray(e.fields) ? e.fields : []) {
3449
3536
  if (!FORM_FIELD_TYPES.includes(String(f?.type))) {
3450
3537
  at('INLINE_AGENT_ESCALATION_FIELD_TYPE_UNKNOWN', `Inline agent step "${step}"'s quick-form escalation gives the field "${String(f?.id ?? '?')}" the type `
3451
- + `${JSON.stringify(f?.type)}, which the form does not recognize — it would be published as a plain `
3452
- + `string. Valid: ${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
3538
+ + `${JSON.stringify(f?.type)}, ${formFieldTypeWhy(f?.type, `which the form does not recognize — it would `
3539
+ + `be published as a plain string`)}. Valid: ${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
3453
3540
  }
3454
3541
  }
3455
3542
  continue;
@@ -3831,7 +3918,7 @@ function armTails(steps, incoming) {
3831
3918
  tails = [];
3832
3919
  continue;
3833
3920
  }
3834
- if (st.kind === 'return' || st.kind === 'terminate' || st.kind === 'break')
3921
+ if (st.kind === 'return' || st.kind === 'terminate' || st.kind === 'break' || st.kind === 'continue')
3835
3922
  return [];
3836
3923
  if (st.kind === 'action') {
3837
3924
  tails = actionTails(st.name, st.spec, st.options?.version);
@@ -4165,12 +4252,28 @@ function checkHttp(step, inputs, diags) {
4165
4252
  }
4166
4253
  }
4167
4254
  }
4255
+ /**
4256
+ * Why a form-field type is refused: a product spelling of a type the SDK
4257
+ * models (`"string"` is the platform's own name for text), or a type
4258
+ * the SDK does not model at all. `unknownWhy` is the caller's reason for the
4259
+ * second case; the default is a human task's.
4260
+ */
4261
+ function formFieldTypeWhy(type, unknownWhy = `which the platform does not recognize. Older runtimes read it as text, newer ones reject `
4262
+ + `the form`) {
4263
+ const alias = sdkFormFieldType(type);
4264
+ return alias !== undefined
4265
+ ? `which is the platform's spelling of the SDK's '${alias}' — the SDK writes '${alias}'`
4266
+ : unknownWhy;
4267
+ }
4168
4268
  /** Every FormFieldType, once; `Record<FormFieldType, …>` makes tsc refuse a missing or extra one. */
4169
4269
  const FORM_FIELD_TYPE_SET = { text: true, number: true, boolean: true, 'date-time': true };
4170
- /** Form-field types the platform recognizes; anything else normalizes to text. */
4270
+ /** The form-field types the SDK models. */
4171
4271
  const FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPE_SET);
4172
4272
  /** The `suggestion` for an unknown form-field type: the nearest valid one, else `type: 'text'`. */
4173
4273
  function formFieldTypeHint(type) {
4274
+ const alias = sdkFormFieldType(type);
4275
+ if (alias !== undefined)
4276
+ return `type: '${alias}'`;
4174
4277
  const nearest = nearestName(String(type), FORM_FIELD_TYPES);
4175
4278
  return nearest !== undefined ? `type: '${nearest}'` : `type: 'text'`;
4176
4279
  }
@@ -4182,18 +4285,6 @@ const RESERVED_ANSWER_IDS = new Set(['output', 'status', 'Action']);
4182
4285
  * family — same availability note as do-while).
4183
4286
  */
4184
4287
  const HITL_VARIANTS = ['quick-form', 'action-app', 'document-validation'];
4185
- /**
4186
- * An outcome's stable id — the serializer's own slug rule, mirrored so the
4187
- * `outcome-<id>` port names this file validates are the ones that get emitted.
4188
- */
4189
- function outcomeSlug(name, index) {
4190
- const slug = String(name ?? '')
4191
- .trim()
4192
- .toLowerCase()
4193
- .replace(/[^a-z0-9]+/g, '-')
4194
- .replace(/^-+|-+$/g, '');
4195
- return slug || `outcome-${index + 1}`;
4196
- }
4197
4288
  /**
4198
4289
  * Why a base or quick-form task continues every outcome to the next step, as a
4199
4290
  * clause for a diagnostic. `pinned` is the step's `{ version }`.
@@ -4263,10 +4354,9 @@ function reportUnreachableHitlOutcomes(choices, exprs, diags) {
4263
4354
  for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)\s*\[/g))
4264
4355
  readsOutcome.add(m[1]);
4265
4356
  }
4266
- for (const [step, { names, ends, variant, pinned, portsOff }] of choices) {
4357
+ for (const [step, { names, ids, ends, variant, pinned, portsOff }] of choices) {
4267
4358
  if (readsOutcome.has(step))
4268
4359
  continue;
4269
- const ids = names.map((n, i) => outcomeSlug(n, i));
4270
4360
  // The base node and quick-form draw a handle per outcome, so both can route
4271
4361
  // it — `.stepSwitch`, or `outcomePorts: true` (a pin keeps its version). An
4272
4362
  // app-backed variant cannot: the Action read is its only remedy.
@@ -4449,10 +4539,8 @@ function checkHitl(step, inputs, diags) {
4449
4539
  return;
4450
4540
  }
4451
4541
  if (!FORM_FIELD_TYPES.includes(String(f?.type))) {
4452
- at('HITL_FIELD_TYPE_UNKNOWN', `Human task "${step}" gives the form field "${id}" the type ${JSON.stringify(f?.type)}, which `
4453
- + `the platform does not recognize — it normalizes an unknown type to text, so the reviewer `
4454
- + `gets a text box and the value comes back as a string. Valid: `
4455
- + `${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
4542
+ at('HITL_FIELD_TYPE_UNKNOWN', `Human task "${step}" gives the form field "${id}" the type ${JSON.stringify(f?.type)}, `
4543
+ + `${formFieldTypeWhy(f?.type)}. Valid: ${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
4456
4544
  }
4457
4545
  if (isAnswerField(f))
4458
4546
  answers++; // an `output`, or an `inOut` whose reviewed value comes back
@@ -4491,8 +4579,26 @@ function checkHitl(step, inputs, diags) {
4491
4579
  return;
4492
4580
  }
4493
4581
  const names = new Set();
4582
+ const ids = new Map();
4494
4583
  outcomes.forEach((o, i) => {
4495
4584
  const name = (typeof o === 'string' ? o : o?.name)?.trim();
4585
+ // Two outcomes on one id would share one `outcome-<id>` port, so the
4586
+ // second's edge could never be told apart. An explicit `id` can collide
4587
+ // with another outcome's derived slug.
4588
+ const id = outcomeIdOf(o, i);
4589
+ const explicitId = typeof o === 'object' && o !== null ? o.id : undefined;
4590
+ if (explicitId !== undefined && (typeof explicitId !== 'string' || explicitId === '')) {
4591
+ at('HITL_OUTCOME_BAD_ID', `Human task "${step}" outcome ${JSON.stringify(name ?? i)} has an id that is not a non-empty string. `
4592
+ + `Leave \`id\` out to use the slug of the name.`, `{ name: ${JSON.stringify(name ?? 'Approve')} }`);
4593
+ }
4594
+ else if (ids.has(id) && ids.get(id) !== String(name ?? '')) {
4595
+ // Same name, same id: HITL_DUP_OUTCOME below reports it once, as a name clash.
4596
+ at('HITL_DUP_OUTCOME_ID', `Human task "${step}" gives outcomes "${ids.get(id)}" and "${name}" the same id "${id}", so both would `
4597
+ + `leave from one 'outcome-${id}' port. Give one of them a different \`id\`.`);
4598
+ }
4599
+ else {
4600
+ ids.set(id, String(name ?? ''));
4601
+ }
4496
4602
  if (!name) {
4497
4603
  at('HITL_OUTCOME_NO_NAME', `Human task "${step}" has an outcome with no name (outcomes[${i}]). The result carries the `
4498
4604
  + `outcome's NAME — it is what "$vars.${step}.output.Action" holds and what the platform's `
@@ -5227,14 +5333,14 @@ function checkStepRefs(built, diags) {
5227
5333
  index(built.steps, false, '', undefined);
5228
5334
  /** Every ref, with where it sits. */
5229
5335
  const found = [];
5230
- const collect = (steps, from, scope, arm) => {
5336
+ const collect = (steps, from, scope, arm, enclosing = []) => {
5231
5337
  let source = from;
5232
5338
  for (const [i, s] of steps.entries()) {
5233
5339
  if (s.kind === 'action')
5234
5340
  source = s.name;
5235
5341
  if (s.kind === 'stepToRef') {
5236
5342
  found.push({
5237
- target: s.target, port: s.port, from: source, scope, arm,
5343
+ target: s.target, port: s.port, from: source, scope, arm, enclosing,
5238
5344
  // Only a default-port ref hands the path off, so only it can strand
5239
5345
  // what follows; a side exit leaves the chain running.
5240
5346
  after: s.port === 'output' ? steps.length - i - 1 : 0,
@@ -5242,23 +5348,23 @@ function checkStepRefs(built, diags) {
5242
5348
  continue;
5243
5349
  }
5244
5350
  if (s.kind === 'stepToList')
5245
- collect(s.body, source, scope, arm);
5351
+ collect(s.body, source, scope, arm, enclosing);
5246
5352
  else if (s.kind === 'branch') {
5247
- collect(s.then, source, scope, arm);
5248
- collect(s.otherwise, source, scope, arm);
5353
+ collect(s.then, source, scope, arm, enclosing);
5354
+ collect(s.otherwise, source, scope, arm, enclosing);
5249
5355
  }
5250
5356
  else if (s.kind === 'switch') {
5251
- s.cases.forEach((c) => collect(c.body, source, scope, arm));
5252
- collect(s.default ?? [], source, scope, arm);
5357
+ s.cases.forEach((c) => collect(c.body, source, scope, arm, enclosing));
5358
+ collect(s.default ?? [], source, scope, arm, enclosing);
5253
5359
  }
5254
5360
  else if (s.kind === 'parallel')
5255
- s.arms.forEach((a, i) => collect(a, source, scope, `${s.name}#${i}`));
5361
+ s.arms.forEach((a, i) => collect(a, source, scope, `${s.name}#${i}`, enclosing));
5256
5362
  else if (s.kind === 'loop' || s.kind === 'doWhile')
5257
- collect(s.body, source, s.name, arm);
5363
+ collect(s.body, source, s.name, arm, [...enclosing, s.name]);
5258
5364
  }
5259
5365
  };
5260
5366
  collect(built.steps, undefined, '', undefined);
5261
- for (const { target, from, after, scope, arm } of found) {
5367
+ for (const { target, from, after, scope, arm, enclosing } of found) {
5262
5368
  const where = from ? `after "${from}"` : 'at the start of the flow';
5263
5369
  const say = (level, code, message, suggestion) => {
5264
5370
  diags.push({ level, code, message, step: from ?? 'stepToRef', ...(suggestion ? { suggestion } : {}) });
@@ -5269,6 +5375,16 @@ function checkStepRefs(built, diags) {
5269
5375
  + `${near ? ` (did you mean "${near}"?)` : ''}. It takes the same name you passed to .step().`, near ? `.stepToRef('${near}')` : undefined);
5270
5376
  continue;
5271
5377
  }
5378
+ // A ref to a container the path is INSIDE names it as a step, so the edge
5379
+ // enters it from its outer input: it starts the loop over from its first
5380
+ // item, while the iteration the path was in never finishes. What the author
5381
+ // means is almost always the next item, or out of the loop (#1129).
5382
+ if (enclosing.includes(target)) {
5383
+ say('error', 'REF_TO_OWN_LOOP', `.stepToRef('${target}') ${where} names the loop it is inside. That edge enters "${target}" from its outer `
5384
+ + `input, which starts the loop over instead of going on to the next item. Use .continue() to go on to the `
5385
+ + `next iteration, or .break() to leave the loop.`, '.continue()');
5386
+ continue;
5387
+ }
5272
5388
  if (inPortList.has(target)) {
5273
5389
  say('error', 'REF_INTO_PORT_LIST', `.stepToRef('${target}') ${where} targets a step inside another port's path. That path runs only when ITS `
5274
5390
  + `port fired — it is not somewhere another edge can jump into. Point at a step on the main path, or end `
@@ -5287,12 +5403,15 @@ function checkStepRefs(built, diags) {
5287
5403
  + `iteration, so those have no value. Point at a step after the loop, or do this work inside the body.`);
5288
5404
  continue;
5289
5405
  }
5290
- // OUT of a loop body is a `break`. Flow has no break or continue node, and
5291
- // whether the runtime accepts an edge leaving a bpmn:SubProcess this way is
5292
- // not measured — so this warns and leaves the verdict to `validate`.
5293
- say('warning', 'REF_OUT_OF_LOOP', `.stepToRef('${target}') ${where} leaves the body of loop "${scope}" — a break. Flow has no break or `
5294
- + `continue node, and whether the runtime honours an edge out of a loop body is not something this checker `
5295
- + `can prove. Verify with uip maestro flow validate before relying on it.`);
5406
+ // OUT of a loop body to a step after it. The container's own handles are
5407
+ // the measured ways out — `.break()` (its break handle) and `.continue()`
5408
+ // (its continue handle). Whether the runtime accepts an edge leaving a
5409
+ // bpmn:SubProcess some other way is not measured, so this warns and leaves
5410
+ // the verdict to `validate`.
5411
+ say('warning', 'REF_OUT_OF_LOOP', `.stepToRef('${target}') ${where} leaves the body of loop "${scope}" by an edge straight to a step outside it. `
5412
+ + `To leave the loop, use .break() (the loop's break handle); to go on to the next item, use .continue(). `
5413
+ + `Whether the runtime honours any other edge out of a loop body is not something this checker can prove: `
5414
+ + `verify with uip maestro flow validate before relying on it.`);
5296
5415
  }
5297
5416
  // Best effort from here: the mechanism is understood, the runtime's behaviour
5298
5417
  // is not measured, so these WARN and leave the verdict to `validate`.
@@ -5316,6 +5435,23 @@ function checkStepRefs(built, diags) {
5316
5435
  }
5317
5436
  }
5318
5437
  }
5438
+ /**
5439
+ * LOOP_EXIT_IN_PARALLEL_ARM: a `.continue()` inside a `.parallel` arm sends
5440
+ * that arm's token to the container's continue handle, so it never reaches the
5441
+ * arm's Merge. The Merge is a bpmn:ParallelGateway that waits for every branch
5442
+ * it forked, and the iteration it belongs to is still running: the join never
5443
+ * fires and the iteration hangs. (`.break()` leaves the whole container, so
5444
+ * whether the runtime cancels the pending Merge is a different, unmeasured
5445
+ * question; it is not refused here.)
5446
+ */
5447
+ function loopExitInArm() {
5448
+ return {
5449
+ level: 'error', code: 'LOOP_EXIT_IN_PARALLEL_ARM',
5450
+ message: `.continue() inside a .parallel() arm sends that arm's path to the loop's continue handle, so it never `
5451
+ + `reaches the arm's Merge, which waits for every branch it forked: the join never fires and the iteration `
5452
+ + `hangs. Put the .continue() after the .parallel(), on a .branch() that reads what the arms produced.`,
5453
+ };
5454
+ }
5319
5455
  /**
5320
5456
  * A field read off `$vars.<step>.error` — the envelope, and the one shape the
5321
5457
  * authoring surface writes.
@@ -108,6 +108,7 @@ export function registerFlowCommands(program) {
108
108
  .option('--original <file>', '(required) original .flow path, the artifact to merge into')
109
109
  .option('-o, --output <file>', 'merged output path')
110
110
  .option('--baseline <file>', 'compiled pristine decompile')
111
+ .option('--strict', 'refuse when the no-edit round trip (--baseline) differs from the original')
111
112
  .allowExcessArguments()
112
113
  .action(async (_edited, options, command) => {
113
114
  const pair = namedOrLegacySecond(command, {
@@ -120,6 +121,8 @@ export function registerFlowCommands(program) {
120
121
  const argv = [original, edited];
121
122
  addValue(argv, '-o', options.output);
122
123
  addValue(argv, '--baseline', options.baseline);
124
+ if (options.strict)
125
+ argv.push('--strict');
123
126
  await invoke('flow merge', () => loadTool(import.meta.url, '../../merge-cli.js'), argv);
124
127
  });
125
128
  }
@@ -167,7 +167,14 @@ export async function run(argv) {
167
167
  // families the SDK cannot into the same directory (including `internal`
168
168
  // built-ins such as `load-attachments`), so anything else found there is
169
169
  // someone else's and is left alone.
170
+ //
171
+ // Owning the TYPE is not owning the FILE, though. Decompile keeps a canvas
172
+ // agent's `source` directory, so compiling decompiled source to another `.flow`
173
+ // in the same project writes into the ORIGINAL flow's agent directory, and the
174
+ // canvas resources there are still that flow's. A resource another
175
+ // `.flow` beside this one references by `inputs.source` is never pruned.
170
176
  const removedResources = [];
177
+ const referencedElsewhere = resourceSourcesOfOtherFlows(outDir, resolve(out));
171
178
  const owned = new Set(sidecars
172
179
  .filter((s) => /\/resources\/[^/]+\/resource\.json$/.test(s.path))
173
180
  .map((s) => resolve(outDir, s.path)));
@@ -182,6 +189,11 @@ export async function run(argv) {
182
189
  const file = join(resourcesDir, entry, 'resource.json');
183
190
  if (!existsSync(file) || owned.has(file))
184
191
  continue;
192
+ const user = referencedElsewhere.get(entry);
193
+ if (user !== undefined) {
194
+ console.log(`compile: kept ${join('resources', entry, 'resource.json')}: ${user} still references it`);
195
+ continue;
196
+ }
185
197
  let body;
186
198
  try {
187
199
  body = JSON.parse(readFileSync(file, 'utf8'));
@@ -248,3 +260,42 @@ function serializeWarningDiagnostic(w) {
248
260
  return { level: 'warning', code: w.code, message: w.message };
249
261
  }
250
262
  runWhenInvokedDirectly(import.meta.url, 'compile', run);
263
+ /**
264
+ * The `inputs.source` uuids the OTHER `.flow` files in `dir` reference, each
265
+ * mapped to the first file that does. `dir` is the namespace sidecar paths are
266
+ * relative to, so these are the agent resources another flow still uses. The
267
+ * flow being written (`self`) is left out: what it no longer references is what
268
+ * pruning is for. Subflow bodies are included; an unreadable file contributes
269
+ * nothing.
270
+ */
271
+ function resourceSourcesOfOtherFlows(dir, self) {
272
+ const users = new Map();
273
+ let entries;
274
+ try {
275
+ entries = readdirSync(dir).filter((f) => f.endsWith('.flow') && resolve(dir, f) !== self);
276
+ }
277
+ catch {
278
+ return users;
279
+ }
280
+ for (const f of entries) {
281
+ let flow;
282
+ try {
283
+ flow = JSON.parse(readFileSync(join(dir, f), 'utf8'));
284
+ }
285
+ catch {
286
+ continue;
287
+ }
288
+ const walk = (scope) => {
289
+ const s = scope;
290
+ for (const n of Array.isArray(s?.nodes) ? s.nodes : []) {
291
+ const src = n?.inputs?.source;
292
+ if (typeof src === 'string' && !users.has(src))
293
+ users.set(src, f);
294
+ }
295
+ if (s?.subflows && typeof s.subflows === 'object')
296
+ Object.values(s.subflows).forEach(walk);
297
+ };
298
+ walk(flow);
299
+ }
300
+ return users;
301
+ }
@@ -190,9 +190,9 @@ export interface ConnectorOpts {
190
190
  object?: string;
191
191
  }
192
192
  /**
193
- * A form field's type. These are the platform's own strings — an unrecognized
194
- * one is NOT an error there, it silently normalizes to text (so a number field
195
- * becomes a text box), which is why `check` rejects anything outside this set.
193
+ * A form field's type. The platform does not recognize a type outside this set:
194
+ * older runtimes read it as text (so a number field becomes a text box), newer
195
+ * ones reject the form. That is why `check` rejects anything outside this set.
196
196
  */
197
197
  export type FormFieldType = 'text' | 'number' | 'boolean' | 'date-time';
198
198
  /** A value the reviewer READS: shown on the form, filled in from the flow. */
@@ -330,12 +330,20 @@ export interface DocValidationInputs {
330
330
  * rather than continuing (the platform's own two-value vocabulary — anything
331
331
  * that is not exactly `'End'` reads as continue).
332
332
  *
333
+ * `id` is the outcome's wire id — `schema.outcomes[].id`, and the `outcome-<id>`
334
+ * port its edge leaves from. Leave it out and the id is a slug of `name`
335
+ * (`'Needs info'` → `needs-info`). Set it only to keep an id a flow already
336
+ * has, as decompile does for a task the designer drew
337
+ * (`{ name: 'Reject', id: 'outcome-1778015958049' }`): merge keeps the
338
+ * original node, so its edges must use that node's ids.
339
+ *
333
340
  * @remarks
334
341
  * A bare string is shorthand for `{ name }`.
335
342
  */
336
343
  export type Outcome = string | {
337
344
  name: string;
338
345
  action?: 'Continue' | 'End';
346
+ id?: string;
339
347
  };
340
348
  /**
341
349
  * WHICH human-task node type to emit. The platform ships three spellings of the
@@ -1132,7 +1140,7 @@ export interface AgentInputs {
1132
1140
  *
1133
1141
  * **Display-only, and it is the only thing that differs.** `registry get` on a
1134
1142
  * coded and a low-code agent returns the same node type, the same
1135
- * `Orchestrator.StartAgentJob` service type, the same `sortOrder: 505` and the
1143
+ * `Orchestrator.StartAgentJob` service type, the same `sortOrder` and the
1136
1144
  * same bindings shape; the single difference is `display.icon` —
1137
1145
  * `coded-agent` vs `autonomous-agent`. Which one a given resource is cannot be
1138
1146
  * known offline, so it is stated here rather than guessed. Check with
@@ -2607,7 +2615,7 @@ export declare function agenticProcess(inputs: AgenticProcessInputs): ActionSpec
2607
2615
  * @remarks
2608
2616
  * **Coded and low-code agents are the same node.** This one factory authors both.
2609
2617
  * `registry get` on a coded agent and on a low-code one returns the same node type,
2610
- * the same service type, the same `sortOrder: 505` and the same bindings shape; the
2618
+ * the same service type, the same `sortOrder` and the same bindings shape; the
2611
2619
  * only difference is the icon, which is what `flavour` sets. Which kind a resource
2612
2620
  * is a property of what somebody published, not of the flow — so if a task asks for
2613
2621
  * "a coded agent", what it is asking you to get right is the RESOURCE you point at.
@@ -609,7 +609,7 @@ export function agenticProcess(inputs) {
609
609
  * @remarks
610
610
  * **Coded and low-code agents are the same node.** This one factory authors both.
611
611
  * `registry get` on a coded agent and on a low-code one returns the same node type,
612
- * the same service type, the same `sortOrder: 505` and the same bindings shape; the
612
+ * the same service type, the same `sortOrder` and the same bindings shape; the
613
613
  * only difference is the icon, which is what `flavour` sets. Which kind a resource
614
614
  * is a property of what somebody published, not of the flow — so if a task asks for
615
615
  * "a coded agent", what it is asking you to get right is the RESOURCE you point at.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * What a `.flow` binding row binds, as one comparable key.
3
+ *
4
+ * The key is `(resource, resourceKey, resourceSubType, propertyAttribute, name)`.
5
+ * It is NOT the row `id`: nodes never reference a row by id, and the two
6
+ * producers name rows differently (compile writes `b<Name><Prop>`, the canvas a
7
+ * random 9-character id), so two rows for the same property never share an id.
8
+ *
9
+ * `name` is part of the key. For a connection row it is the definition's own
10
+ * `<bindings.NAME>` placeholder, and two definitions sharing one connection
11
+ * spell it differently (`uipath-uipath-http connection`, `uipath-salesforce-slack
12
+ * connection`); each needs its own row, which is how compile keys them
13
+ * (`serialize.ts`, flow-builder-sdk#865). Process and queue rows carry
14
+ * `name === propertyAttribute`, so the extra field does not split them.
15
+ *
16
+ * Example: `{ resource: 'process', resourceKey: 'e05d...', resourceSubType: 'Flow',
17
+ * propertyAttribute: 'name', name: 'name' }` has the same identity whether its
18
+ * id is `bCalleeName` or `b7CE8ALjn`.
19
+ */
20
+ export declare function bindingIdentity(b: {
21
+ readonly [k: string]: unknown;
22
+ }): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What a `.flow` binding row binds, as one comparable key.
3
+ *
4
+ * The key is `(resource, resourceKey, resourceSubType, propertyAttribute, name)`.
5
+ * It is NOT the row `id`: nodes never reference a row by id, and the two
6
+ * producers name rows differently (compile writes `b<Name><Prop>`, the canvas a
7
+ * random 9-character id), so two rows for the same property never share an id.
8
+ *
9
+ * `name` is part of the key. For a connection row it is the definition's own
10
+ * `<bindings.NAME>` placeholder, and two definitions sharing one connection
11
+ * spell it differently (`uipath-uipath-http connection`, `uipath-salesforce-slack
12
+ * connection`); each needs its own row, which is how compile keys them
13
+ * (`serialize.ts`, flow-builder-sdk#865). Process and queue rows carry
14
+ * `name === propertyAttribute`, so the extra field does not split them.
15
+ *
16
+ * Example: `{ resource: 'process', resourceKey: 'e05d...', resourceSubType: 'Flow',
17
+ * propertyAttribute: 'name', name: 'name' }` has the same identity whether its
18
+ * id is `bCalleeName` or `b7CE8ALjn`.
19
+ */
20
+ export function bindingIdentity(b) {
21
+ return JSON.stringify([
22
+ b.resource ?? null,
23
+ b.resourceKey ?? null,
24
+ b.resourceSubType ?? null,
25
+ b.propertyAttribute ?? null,
26
+ b.name ?? null,
27
+ ]);
28
+ }