@uipath/maestro-builder-sdk 6.66.1 → 6.66.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,3 +31,19 @@ export interface ApplyPreservationOptions {
31
31
  * same operation compile performs with the payload the source carries.
32
32
  */
33
33
  export declare function applyPreservation(result: Record<string, unknown>, preservation: CaseJsonPreservation | undefined, options?: ApplyPreservationOptions): Record<string, unknown>;
34
+ /**
35
+ * The payload with each stage label and task name an edit renamed rewritten to
36
+ * the one compiled, so a renamed stage or task keeps its ids and wire fields
37
+ * instead of reading as a delete plus an add. Stages left over on both sides
38
+ * pair by the most task names shared, then the fewest not shared, then in order
39
+ * when as many are left on each side; a task left over pairs with one in the
40
+ * same stage whose input and output rows have the same names, and a task gone
41
+ * from its stage that another stage now holds under the same name and rows
42
+ * moved there.
43
+ *
44
+ * `wanted` is the payload's side (identities read from the original plan),
45
+ * `compiled` this compile's. Per stage, `missing` tasks are the payload's that
46
+ * the compiled stage no longer names, `extra` tasks the compiled stage's that
47
+ * the payload does not.
48
+ */
49
+ export declare function followRenames(result: Record<string, unknown>, preservation: CaseJsonPreservation): CaseJsonPreservation;
@@ -9,7 +9,7 @@
9
9
  * name) are resolved here to the generated ids.
10
10
  */
11
11
  import { IdGen, offlineBindingSeed } from './ids.js';
12
- import { isUnrestorableSlotId } from './preserve.js';
12
+ import { isUnrestorableSlotId, taskFingerprint } from './preserve.js';
13
13
  import { v31TaskSets } from './task-sets.js';
14
14
  import { camelizeName, defaultActionOutputVar } from './action-output-var.js';
15
15
  import { buildConnectorInputs, nestConnectorBody, replayInputsOf, transportHttpMethod } from '../core/connector-inputs.js';
@@ -23,6 +23,7 @@ import { EXTERNAL_TASK_DESCRIPTOR_CONTRACT, EXTERNAL_TASK_HYBRID_UNSUPPORTED, ex
23
23
  import { CASE_FORMAT_PROFILE, CASE_SCHEMA_VERSIONS } from './format-profile.js';
24
24
  import { isResourceLabelRefusal, resolveResourceRef, resourceIdentity } from './resource-identity.js';
25
25
  import { ownEntry } from '../core/own-entry.js';
26
+ import { stableId } from '../core/stable-id.js';
26
27
  import { resolveSchemaVersion } from '../schema-version.js';
27
28
  import { caseJsonPreservation, caseJsonPreservationErrors, } from './preserve.js';
28
29
  import { businessEventProblems } from './business-event-shape.js';
@@ -30,6 +31,13 @@ import { readEventFilterLeaf, eventFilterTreeLeaf } from '../core/event-filters.
30
31
  import { responseJsonSchema } from '../core/connector-response-schema.js';
31
32
  import { ruleShapeProblems } from './rule-options.js';
32
33
  import { CompileRefusal } from '../core/compile-refusal.js';
34
+ /** The ids a serialize pass hashed from element ids, by the document it returned. */
35
+ const ELEMENT_SEEDED_IDS = new WeakMap();
36
+ function elementSeededId(resolver, elementId, suffix) {
37
+ const id = stableId(`${elementId}${suffix}`);
38
+ resolver.elementSeededIds.push({ elementId, suffix, id });
39
+ return id;
40
+ }
33
41
  /**
34
42
  * Condition `displayName` allocator.
35
43
  *
@@ -102,6 +110,7 @@ export function serialize(built, opts = {}) {
102
110
  escalationIdByName: new Map(),
103
111
  businessEvents: new Set((built.businessEvents ?? []).map((event) => event.name)),
104
112
  conditionName: conditionNamer(built),
113
+ elementSeededIds: [],
105
114
  };
106
115
  // Preallocate SLA + escalation ids (before any rule is emitted) so a
107
116
  // `sla-status-change` rule can resolve the `slaId`/`escalationId` it points at.
@@ -282,6 +291,7 @@ export function serialize(built, opts = {}) {
282
291
  }
283
292
  : {}),
284
293
  };
294
+ ELEMENT_SEEDED_IDS.set(result, resolver.elementSeededIds);
285
295
  const plan = stampConnectorConfigVersion(applyAuthorLayout(applyPreservation(result, caseJsonPreservation(built)), built.layout));
286
296
  // Last, over the finished document: V32 requires case-unique condition names,
287
297
  // and an author (or a decompiled pre-V32 plan) can repeat one. Rename the
@@ -396,6 +406,7 @@ export function applyPreservation(result, preservation, options = {}) {
396
406
  const errors = caseJsonPreservationErrors(preservation);
397
407
  if (errors.length)
398
408
  throw new CompileRefusal('PRESERVATION_COLLISION', `invalid Case JSON preservation payload: ${errors.join('; ')}`);
409
+ preservation = followRenames(result, preservation);
399
410
  applyPreservedIdentities(result, preservation);
400
411
  Object.assign(result, preservation.topLevel ?? {});
401
412
  Object.assign(result.metadata, preservation.metadata ?? {});
@@ -431,9 +442,15 @@ export function applyPreservation(result, preservation, options = {}) {
431
442
  const row = rows.find((candidate) => candidate.name === name);
432
443
  if (!row && options.overwrite)
433
444
  continue;
445
+ // `custom` alone has nothing to mark when no row is written: an older SDK's
446
+ // companion for a variable an output writes, which this one migrates away.
447
+ if (!row && Object.keys(fields).every((field) => field === 'custom'))
448
+ continue;
434
449
  if (!row)
435
450
  throw new CompileRefusal('PRESERVATION_TARGET_MISSING', `preserved variable "${name}" was not regenerated in variables.${lane}.`);
436
451
  for (const [field, value] of Object.entries(fields)) {
452
+ if (row[field] === value)
453
+ continue;
437
454
  if (!options.overwrite && Object.prototype.hasOwnProperty.call(row, field))
438
455
  throw new CompileRefusal('PRESERVATION_FIELD_COLLISION', `preserved variable "${name}" field "${field}" collides with serializer-owned output.`);
439
456
  row[field] = value;
@@ -524,14 +541,171 @@ export function applyPreservation(result, preservation, options = {}) {
524
541
  nodes.sort((left, right) => (order.get(String(left.id ?? '')) ?? Number.MAX_SAFE_INTEGER)
525
542
  - (order.get(String(right.id ?? '')) ?? Number.MAX_SAFE_INTEGER));
526
543
  }
544
+ // A stage the payload names that was not regenerated was deleted by the edit:
545
+ // its edges and canvas position go with it.
546
+ const nodeIds = new Set(result.nodes.map((node) => node.id));
547
+ const gone = new Set((preservation.identities?.stages ?? []).flatMap((stage) => (stage.id && !nodeIds.has(stage.id) ? [stage.id] : [])));
527
548
  if (preservation.edges !== undefined)
528
- result.edges = preservation.edges;
549
+ result.edges = preservation.edges.filter((edge) => !gone.has(edge?.source) && !gone.has(edge?.target));
529
550
  if (preservation.layout !== undefined)
530
- result.layout = preservation.layout;
551
+ result.layout = withoutLayoutNodes(preservation.layout, gone);
531
552
  else if (preservation.omitLayout)
532
553
  delete result.layout;
533
554
  return result;
534
555
  }
556
+ function withoutLayoutNodes(layout, gone) {
557
+ const nodes = layout?.nodes;
558
+ if (!gone.size || !nodes || typeof nodes !== 'object' || !Object.keys(nodes).some((id) => gone.has(id)))
559
+ return layout;
560
+ return { ...layout, nodes: Object.fromEntries(Object.entries(nodes).filter(([id]) => !gone.has(id))) };
561
+ }
562
+ /**
563
+ * The payload with each stage label and task name an edit renamed rewritten to
564
+ * the one compiled, so a renamed stage or task keeps its ids and wire fields
565
+ * instead of reading as a delete plus an add. Stages left over on both sides
566
+ * pair by the most task names shared, then the fewest not shared, then in order
567
+ * when as many are left on each side; a task left over pairs with one in the
568
+ * same stage whose input and output rows have the same names, and a task gone
569
+ * from its stage that another stage now holds under the same name and rows
570
+ * moved there.
571
+ *
572
+ * `wanted` is the payload's side (identities read from the original plan),
573
+ * `compiled` this compile's. Per stage, `missing` tasks are the payload's that
574
+ * the compiled stage no longer names, `extra` tasks the compiled stage's that
575
+ * the payload does not.
576
+ */
577
+ export function followRenames(result, preservation) {
578
+ const wanted = preservation.identities?.stages ?? [];
579
+ if (!wanted.length)
580
+ return preservation;
581
+ const compiled = (result.nodes ?? []).filter((node) => String(node.type ?? '').endsWith('Stage'));
582
+ const labelOf = (stage) => String(stage.data?.label ?? '');
583
+ const tasksOf = (stage) => (stage.data?.tasks ?? []).flat().filter((task) => task && typeof task === 'object');
584
+ const compiledLabels = new Set(compiled.map(labelOf));
585
+ const wantedLabels = new Set(wanted.map((stage) => stage.label));
586
+ const unmatchedCompiled = compiled.filter((stage) => !wantedLabels.has(labelOf(stage)));
587
+ const stageLabels = new Map();
588
+ const paired = new Set();
589
+ const unmatchedWanted = wanted.filter((stage) => !compiledLabels.has(stage.label));
590
+ // Ranked across every pair, so a deleted stage that shares one task name with
591
+ // a renamed stage cannot take it from the stage that shares more.
592
+ const stagePairs = unmatchedWanted.flatMap((stage, from) => {
593
+ const names = new Set((stage.tasks ?? []).flat().map((task) => task.name));
594
+ return unmatchedCompiled.flatMap((candidate, to) => {
595
+ const tasks = tasksOf(candidate);
596
+ const shared = tasks.filter((task) => names.has(String(task.displayName ?? ''))).length;
597
+ return shared ? [{ from, to, shared, unshared: names.size + tasks.length - 2 * shared }] : [];
598
+ });
599
+ });
600
+ stagePairs.sort((a, b) => b.shared - a.shared || a.unshared - b.unshared || a.from - b.from || a.to - b.to);
601
+ for (const { from, to } of stagePairs) {
602
+ if (stageLabels.has(unmatchedWanted[from].label) || paired.has(unmatchedCompiled[to]))
603
+ continue;
604
+ paired.add(unmatchedCompiled[to]);
605
+ stageLabels.set(unmatchedWanted[from].label, labelOf(unmatchedCompiled[to]));
606
+ }
607
+ const unpairedWanted = unmatchedWanted.filter((stage) => !stageLabels.has(stage.label));
608
+ const unpairedCompiled = unmatchedCompiled.filter((stage) => !paired.has(stage));
609
+ if (unpairedWanted.length === unpairedCompiled.length)
610
+ unpairedWanted.forEach((stage, index) => stageLabels.set(stage.label, labelOf(unpairedCompiled[index])));
611
+ const rowNames = (inputs, outputs) => JSON.stringify([inputs, outputs].map((rows) => (Array.isArray(rows) ? rows : []).map((row) => String(row?.name ?? ''))));
612
+ const nameOf = (task) => String(task.displayName ?? '');
613
+ const stageSides = wanted.map((stage) => {
614
+ const target = compiled.find((candidate) => labelOf(candidate) === (stageLabels.get(stage.label) ?? stage.label));
615
+ const tasks = (stage.tasks ?? []).flat();
616
+ const current = target ? tasksOf(target) : [];
617
+ const currentNames = new Set(current.map(nameOf));
618
+ const names = new Set(tasks.map((task) => task.name));
619
+ return { stage, target, missing: tasks.filter((task) => !currentNames.has(task.name)), extra: current.filter((task) => !names.has(nameOf(task))) };
620
+ });
621
+ const targeted = new Set(stageSides.map((side) => side.target));
622
+ const extraTasks = [
623
+ ...stageSides.flatMap(({ target, extra }) => (target ? extra.map((task) => ({ stage: labelOf(target), task })) : [])),
624
+ ...compiled.filter((stage) => !targeted.has(stage)).flatMap((stage) => tasksOf(stage).map((task) => ({ stage: labelOf(stage), task }))),
625
+ ];
626
+ const missingTasks = stageSides.flatMap(({ stage, missing }) => missing.map((task) => ({ stage: stage.label, task })));
627
+ const moves = new Map();
628
+ const claimed = new Set();
629
+ for (const { stage, task } of missingTasks) {
630
+ const rows = rowNames(task.inputs, task.outputs);
631
+ const to = extraTasks.filter((extra) => nameOf(extra.task) === task.name && rowNames(extra.task.data?.inputs, extra.task.data?.outputs) === rows);
632
+ if (to.length !== 1 || missingTasks.filter((other) => other.task.name === task.name).length !== 1)
633
+ continue;
634
+ if (!moves.has(stage))
635
+ moves.set(stage, new Map());
636
+ moves.get(stage).set(task.name, to[0].stage);
637
+ claimed.add(to[0].task);
638
+ }
639
+ const movedTo = (label, name) => moves.get(label)?.get(name);
640
+ const taskNames = new Map();
641
+ for (const side of stageSides) {
642
+ if (!side.target)
643
+ continue;
644
+ const missing = side.missing.filter((task) => !movedTo(side.stage.label, task.name));
645
+ const extra = side.extra
646
+ .filter((task) => !claimed.has(task))
647
+ .map((task) => ({ name: nameOf(task), rows: rowNames(task.data?.inputs, task.data?.outputs), task }));
648
+ // A deleted sibling can share a renamed task's rows, so the one whose content
649
+ // the renamed task still has wins, then the old name closest to the new one,
650
+ // then the earlier task.
651
+ const candidates = missing.flatMap((task, from) => extra.flatMap((candidate, to) => candidate.rows === rowNames(task.inputs, task.outputs)
652
+ ? [{ from, to, same: Number(task.fingerprint !== undefined && task.fingerprint === taskFingerprint(candidate.task)), score: nameAffinity(task.name, candidate.name) }]
653
+ : []));
654
+ candidates.sort((a, b) => b.same - a.same || b.score - a.score || a.from - b.from || a.to - b.to);
655
+ const renames = new Map();
656
+ const taken = new Set();
657
+ for (const { from, to } of candidates) {
658
+ if (renames.has(missing[from].name) || taken.has(to))
659
+ continue;
660
+ renames.set(missing[from].name, extra[to].name);
661
+ taken.add(to);
662
+ }
663
+ if (renames.size)
664
+ taskNames.set(side.stage.label, renames);
665
+ }
666
+ if (!stageLabels.size && !taskNames.size && !moves.size)
667
+ return preservation;
668
+ const stageName = (label) => stageLabels.get(label) ?? label;
669
+ const taskName = (label, name) => taskNames.get(label)?.get(name) ?? name;
670
+ const ref = (entry) => ({ ...entry, stage: movedTo(entry.stage, entry.task) ?? stageName(entry.stage), task: taskName(entry.stage, entry.task) });
671
+ const stages = wanted.map((stage) => ({
672
+ ...stage,
673
+ label: stageName(stage.label),
674
+ ...(stage.tasks
675
+ ? { tasks: stage.tasks.map((lane) => lane.filter((task) => !movedTo(stage.label, task.name)).map((task) => ({ ...task, name: taskName(stage.label, task.name) }))) }
676
+ : {}),
677
+ }));
678
+ for (const { stage: from, task } of missingTasks) {
679
+ const to = movedTo(from, task.name);
680
+ if (!to)
681
+ continue;
682
+ const target = stages.find((stage) => stage.label === to);
683
+ if (target)
684
+ target.tasks = [...(target.tasks ?? []), [task]];
685
+ else
686
+ stages.push({ label: to, tasks: [[task]] });
687
+ }
688
+ return {
689
+ ...preservation,
690
+ identities: {
691
+ ...preservation.identities,
692
+ stages,
693
+ },
694
+ ...(preservation.taskFields ? { taskFields: preservation.taskFields.map(ref) } : {}),
695
+ ...(preservation.omitTaskElementIds ? { omitTaskElementIds: preservation.omitTaskElementIds.map(ref) } : {}),
696
+ ...(preservation.omitStagePositions ? { omitStagePositions: preservation.omitStagePositions.map(stageName) } : {}),
697
+ };
698
+ }
699
+ /** How much of a renamed name survives: the longer of the shared prefix and the shared suffix. */
700
+ function nameAffinity(before, after) {
701
+ let prefix = 0;
702
+ while (prefix < before.length && before[prefix] === after[prefix])
703
+ prefix++;
704
+ let suffix = 0;
705
+ while (suffix < before.length && suffix < after.length && before.at(-1 - suffix) === after.at(-1 - suffix))
706
+ suffix++;
707
+ return Math.max(prefix, suffix);
708
+ }
535
709
  function applyPreservedIdentities(result, preservation) {
536
710
  const identities = preservation.identities;
537
711
  if (!identities)
@@ -543,11 +717,70 @@ function applyPreservedIdentities(result, preservation) {
543
717
  replacements.set(current.id, wanted);
544
718
  current.id = wanted;
545
719
  };
720
+ const nodes = result.nodes;
721
+ const stages = nodes.filter((node) => String(node.type).endsWith('Stage'));
722
+ const bagField = CASE_FORMAT_PROFILE.waitConnectorRuleBagField;
723
+ const ruleOutputRows = (conditions) => (conditions ?? []).flatMap((condition) => (condition?.rules ?? []).flat().flatMap((rule) => rule?.[bagField]?.outputs ?? []));
724
+ // Every output row id in the plan, task and rule rows alike, kept current as
725
+ // preserved ids are taken. Output ids are unique plan-wide (`claimOutputId`); a
726
+ // preserved id that another row already holds would undo that. Example: an
727
+ // older SDK wrote two action rows both `{ id: 'action', var: 'action' }`; this
728
+ // pass gives the second `action2`, and taking its preserved `action` would make
729
+ // it a duplicate.
730
+ const heldOutputIds = new Set();
731
+ for (const stage of stages) {
732
+ const rows = [
733
+ ...ruleOutputRows(stage.data?.entryConditions),
734
+ ...ruleOutputRows(stage.data?.exitConditions),
735
+ ...(stage.data?.tasks ?? []).flat().flatMap((task) => [...(task?.data?.outputs ?? []), ...ruleOutputRows(task?.entryConditions)]),
736
+ ];
737
+ for (const row of rows)
738
+ if (typeof row?.id === 'string')
739
+ heldOutputIds.add(row.id);
740
+ }
741
+ for (const row of ruleOutputRows(result.metadata?.caseExitRules))
742
+ if (typeof row?.id === 'string')
743
+ heldOutputIds.add(row.id);
744
+ const takeOutputRows = (outputRows, wantedRows) => {
745
+ // Match by name when unique on both sides, else by position.
746
+ const unique = (names, name) => names.filter((n) => n === name).length === 1;
747
+ const currentNames = outputRows.map((r) => r?.name);
748
+ const wantedNames = wantedRows.map((r) => r.name);
749
+ const named = new Set(wantedRows.filter((r) => unique(wantedNames, r.name) && unique(currentNames, r.name)).map((r) => r.name));
750
+ wantedRows.forEach((row, index) => {
751
+ const target = named.has(row.name)
752
+ ? outputRows.find((r) => r?.name === row.name)
753
+ // A positional match never takes a row a name match owns.
754
+ : named.has(outputRows[index]?.name) ? undefined : outputRows[index];
755
+ const currentId = target?.id;
756
+ // An own-name row's id IS its variable's name (`emitTaskOutput`). A
757
+ // preserved id that differs — an `o<hash>` slot from a plan an older
758
+ // SDK wrote — would rename the variable out from under every
759
+ // `=vars.<name>` reader, so the typed rule wins and the row migrates.
760
+ if (target && OWN_NAME_OUTPUT_ROWS.has(target) && row.id !== target.id)
761
+ return;
762
+ if (target && typeof row.id === 'string' && row.id !== currentId && heldOutputIds.has(row.id))
763
+ return;
764
+ take(target, row.id);
765
+ if (target && typeof row.id === 'string' && typeof currentId === 'string') {
766
+ heldOutputIds.delete(currentId);
767
+ heldOutputIds.add(row.id);
768
+ }
769
+ if (typeof currentId === 'string' && typeof row.id === 'string' && target?.value === currentId) {
770
+ target.value = row.id;
771
+ }
772
+ });
773
+ };
546
774
  const takeConditions = (current, wanted) => {
547
775
  (wanted ?? []).forEach((condition, index) => {
548
776
  const target = current?.[index];
549
777
  take(target, condition.id);
550
- (condition.rules ?? []).forEach((group, groupIndex) => group.forEach((rule, ruleIndex) => take(target?.rules?.[groupIndex]?.[ruleIndex], rule.id)));
778
+ (condition.rules ?? []).forEach((group, groupIndex) => group.forEach((rule, ruleIndex) => {
779
+ const ruleTarget = target?.rules?.[groupIndex]?.[ruleIndex];
780
+ take(ruleTarget, rule.id);
781
+ if (rule.outputs)
782
+ takeOutputRows(ruleTarget?.[bagField]?.outputs ?? [], rule.outputs);
783
+ }));
551
784
  });
552
785
  };
553
786
  const takeSlas = (current, wanted) => {
@@ -558,22 +791,8 @@ function applyPreservedIdentities(result, preservation) {
558
791
  });
559
792
  };
560
793
  take(result, identities.id);
561
- const nodes = result.nodes;
562
794
  const triggerNodes = nodes.filter((node) => node.type === 'case-management:Trigger' || node.type === 'uipath.case.trigger');
563
795
  (identities.triggers ?? []).forEach((trigger, index) => take(triggerNodes[index], trigger.id));
564
- const stages = nodes.filter((node) => String(node.type).endsWith('Stage'));
565
- // Every task-output row id in the plan, kept current as preserved ids are
566
- // taken. Output ids are unique plan-wide (`claimOutputId`); a preserved id that
567
- // another row already holds would undo that. Example: an older SDK wrote two
568
- // action rows both `{ id: 'action', var: 'action' }`; this pass gives the
569
- // second `action2`, and taking its preserved `action` would make it a duplicate.
570
- const heldOutputIds = new Set();
571
- for (const stage of stages)
572
- for (const lane of stage.data?.tasks ?? [])
573
- for (const task of lane)
574
- for (const row of task?.data?.outputs ?? [])
575
- if (typeof row?.id === 'string')
576
- heldOutputIds.add(row.id);
577
796
  for (const stageIdentity of identities.stages ?? []) {
578
797
  const stage = stages.find((candidate) => candidate.data?.label === stageIdentity.label);
579
798
  take(stage, stageIdentity.id);
@@ -585,36 +804,7 @@ function applyPreservedIdentities(result, preservation) {
585
804
  take(task, taskIdentity.id);
586
805
  takeConditions(task?.entryConditions, taskIdentity.entryConditions);
587
806
  (taskIdentity.inputs ?? []).forEach((row, index) => take(task?.data?.inputs?.[index], row.id));
588
- const outputRows = task?.data?.outputs ?? [];
589
- const wantedRows = taskIdentity.outputs ?? [];
590
- // Match by name when unique on both sides, else by position.
591
- const unique = (names, name) => names.filter((n) => n === name).length === 1;
592
- const currentNames = outputRows.map((r) => r?.name);
593
- const wantedNames = wantedRows.map((r) => r.name);
594
- const named = new Set(wantedRows.filter((r) => unique(wantedNames, r.name) && unique(currentNames, r.name)).map((r) => r.name));
595
- wantedRows.forEach((row, index) => {
596
- const target = named.has(row.name)
597
- ? outputRows.find((r) => r?.name === row.name)
598
- // A positional match never takes a row a name match owns.
599
- : named.has(outputRows[index]?.name) ? undefined : outputRows[index];
600
- const currentId = target?.id;
601
- // An own-name row's id IS its variable's name (`emitTaskOutput`). A
602
- // preserved id that differs — an `o<hash>` slot from a plan an older
603
- // SDK wrote — would rename the variable out from under every
604
- // `=vars.<name>` reader, so the typed rule wins and the row migrates.
605
- if (target && OWN_NAME_OUTPUT_ROWS.has(target) && row.id !== target.id)
606
- return;
607
- if (target && typeof row.id === 'string' && row.id !== currentId && heldOutputIds.has(row.id))
608
- return;
609
- take(target, row.id);
610
- if (target && typeof row.id === 'string' && typeof currentId === 'string') {
611
- heldOutputIds.delete(currentId);
612
- heldOutputIds.add(row.id);
613
- }
614
- if (typeof currentId === 'string' && typeof row.id === 'string' && target?.value === currentId) {
615
- target.value = row.id;
616
- }
617
- });
807
+ takeOutputRows(task?.data?.outputs ?? [], taskIdentity.outputs ?? []);
618
808
  }));
619
809
  }
620
810
  const metadata = result.metadata;
@@ -627,11 +817,12 @@ function applyPreservedIdentities(result, preservation) {
627
817
  return;
628
818
  take(variables[lane]?.find((candidate) => candidate.name === row.name), row.id);
629
819
  });
820
+ const longestFirst = [...replacements].sort((a, b) => b[0].length - a[0].length);
630
821
  const replace = (value) => {
631
822
  if (replacements.has(value))
632
823
  return replacements.get(value);
633
824
  let replaced = value;
634
- for (const [current, wanted] of [...replacements].sort((a, b) => b[0].length - a[0].length))
825
+ for (const [current, wanted] of longestFirst)
635
826
  replaced = replaced.replaceAll(current, wanted);
636
827
  return replaced;
637
828
  };
@@ -696,6 +887,33 @@ function applyPreservedIdentities(result, preservation) {
696
887
  rewrite(result);
697
888
  for (const { row, source } of preservedOutputSources)
698
889
  row.source = source;
890
+ // An id hashed from a generated element id is re-minted from the preserved one,
891
+ // wherever it is spelled, including inside a `=jsonString:` configuration.
892
+ const reseeded = new Map();
893
+ for (const entry of ELEMENT_SEEDED_IDS.get(result) ?? []) {
894
+ const elementId = replace(entry.elementId);
895
+ if (elementId !== entry.elementId)
896
+ reseeded.set(entry.id, stableId(`${elementId}${entry.suffix}`));
897
+ }
898
+ if (reseeded.size)
899
+ reseed(result, reseeded);
900
+ }
901
+ function reseed(value, ids) {
902
+ if (!value || typeof value !== 'object')
903
+ return;
904
+ const entries = Array.isArray(value) ? value.map((entry, index) => [index, entry]) : Object.entries(value);
905
+ for (const [key, entry] of entries) {
906
+ if (typeof entry !== 'string') {
907
+ reseed(entry, ids);
908
+ continue;
909
+ }
910
+ let next = entry;
911
+ for (const [from, to] of ids)
912
+ if (next.includes(from))
913
+ next = next.replaceAll(from, to);
914
+ if (next !== entry)
915
+ value[key] = next;
916
+ }
699
917
  }
700
918
  function emitVariables(vars, ids, triggerNodeByRef) {
701
919
  const inputs = [];
@@ -1091,7 +1309,7 @@ function resolvedSubscriptionBag(kind, spec, resolver, elementId, where) {
1091
1309
  const folder = folderBindingRef(resolver, spec.folder, connection, { attribute: 'folder', where, target });
1092
1310
  if (folder)
1093
1311
  context.push(io('folderKey', 'string', { value: `=bindings.${folder.id}` }));
1094
- context.push(io('method', 'string', {}), io('path', 'string', {}), io('objectName', 'string', { value: op.objectName ?? spec.event }), io('operation', 'string', { value: op.eventOperation ?? spec.event }), io('metadata', 'json', { body: subscriptionMetadata(rc, op, spec.filters ?? [], elementId) }));
1312
+ context.push(io('method', 'string', {}), io('path', 'string', {}), io('objectName', 'string', { value: op.objectName ?? spec.event }), io('operation', 'string', { value: op.eventOperation ?? spec.event }), io('metadata', 'json', { body: subscriptionMetadata(rc, op, spec.filters ?? [], elementId, resolver) }));
1095
1313
  return {
1096
1314
  serviceType: kind === 'trigger' ? CASE_FORMAT_PROFILE.eventServiceType : CASE_FORMAT_PROFILE.waitConnectorServiceType,
1097
1315
  context,
@@ -1106,7 +1324,7 @@ function eventInput(where, elementId) {
1106
1324
  ? { name: 'body', type: 'json', target: 'body', body: { queryParams: where }, var: 'vbody', id: 'vbody', elementId }
1107
1325
  : { name: 'body', type: 'json', target: 'body', value: '', var: 'vbody', id: 'vbody', elementId };
1108
1326
  }
1109
- function subscriptionMetadata(rc, op, filters, elementId) {
1327
+ function subscriptionMetadata(rc, op, filters, elementId, resolver) {
1110
1328
  const instanceParameters = {
1111
1329
  connectorKey: rc.connectorKey,
1112
1330
  objectName: op.objectName ?? rc.nodeType.split('.').pop(),
@@ -1131,7 +1349,7 @@ function subscriptionMetadata(rc, op, filters, elementId) {
1131
1349
  const { read } = judged;
1132
1350
  return {
1133
1351
  ...eventFilterTreeLeaf(read),
1134
- uiId: stableUuid(`${elementId}:${read.field}:${String(read.value)}:${index}`),
1352
+ uiId: elementSeededId(resolver, elementId, `:${read.field}:${String(read.value)}:${index}`),
1135
1353
  };
1136
1354
  }).map((leaf) => ({
1137
1355
  id: leaf.id,
@@ -1140,7 +1358,7 @@ function subscriptionMetadata(rc, op, filters, elementId) {
1140
1358
  uiId: leaf.uiId,
1141
1359
  })),
1142
1360
  groups: [],
1143
- uuId: stableUuid(`${elementId}:filters`),
1361
+ uuId: elementSeededId(resolver, elementId, ':filters'),
1144
1362
  } : null;
1145
1363
  const configuration = {
1146
1364
  essentialConfiguration: {
@@ -1182,16 +1400,6 @@ function subscriptionMetadata(rc, op, filters, elementId) {
1182
1400
  },
1183
1401
  };
1184
1402
  }
1185
- function stableUuid(seed) {
1186
- let h1 = 0x811c9dc5;
1187
- let h2 = 0x01000193;
1188
- for (let index = 0; index < seed.length; index++) {
1189
- h1 = (h1 ^ seed.charCodeAt(index)) * 0x01000193 >>> 0;
1190
- h2 = (h2 + seed.charCodeAt(index) * (index + 7)) >>> 0;
1191
- }
1192
- const hex = (value, length) => value.toString(16).padStart(8, '0').slice(0, length);
1193
- return `${hex(h1, 8)}-${hex(h2, 4)}-4${hex(h1 >>> 8, 3)}-8${hex(h2 >>> 8, 3)}-${hex(h1 ^ h2, 8)}${hex(h2, 4)}`;
1194
- }
1195
1403
  // ─── Reference-mode task io-binding (data.inputs[] / data.outputs[]) ───────────
1196
1404
  //
1197
1405
  // Wire shape verified against a real `uip maestro case` caseplan (the io-binding
@@ -80,7 +80,12 @@ export declare function processResourceLocation(spec: {
80
80
  export declare function processResourceBindings(spec: {
81
81
  name: string;
82
82
  folderPath: string;
83
- resourceSubType: ProcessResourceSubType;
83
+ /**
84
+ * The binding's resource kind. `'process'` (the default) for a process
85
+ * reference; `'app'` for an Action App, which carries no subtype.
86
+ */
87
+ resource?: 'process' | 'app';
88
+ resourceSubType?: ProcessResourceSubType;
84
89
  resourceKey?: string;
85
90
  }, existing: ReadonlyMap<string, any>): {
86
91
  ids: {
@@ -63,8 +63,9 @@ export function processResourceLocation(spec) {
63
63
  * process share one pair.
64
64
  */
65
65
  export function processResourceBindings(spec, existing) {
66
+ const resource = spec.resource ?? 'process';
66
67
  const resourceKey = spec.resourceKey ?? `${spec.folderPath}.${spec.name}`;
67
- const found = (attr) => [...existing.values()].find((b) => b.resource === 'process' && b.resourceKey === resourceKey && b.propertyAttribute === attr)?.id;
68
+ const found = (attr) => [...existing.values()].find((b) => b.resource === resource && b.resourceKey === resourceKey && b.propertyAttribute === attr)?.id;
68
69
  const already = { name: found('name'), folderPath: found('folderPath') };
69
70
  if (already.name && already.folderPath) {
70
71
  return { ids: { name: already.name, folderPath: already.folderPath }, entries: [] };
@@ -72,7 +73,7 @@ export function processResourceBindings(spec, existing) {
72
73
  // A readable, stable id per process. Two processes sharing a NAME across folders
73
74
  // would collide, so the id is suffixed until it is free — the queue emitter's
74
75
  // shape, which this is a port of.
75
- const slug = spec.name.replace(/[^A-Za-z0-9]/g, '') || 'Process';
76
+ const slug = spec.name.replace(/[^A-Za-z0-9]/g, '') || (resource === 'app' ? 'App' : 'Process');
76
77
  let base = `b${slug.charAt(0).toUpperCase()}${slug.slice(1)}`;
77
78
  for (let n = 2; existing.has(`${base}Name`) || existing.has(`${base}FolderPath`); n++) {
78
79
  base = `b${slug.charAt(0).toUpperCase()}${slug.slice(1)}${n}`;
@@ -83,21 +84,21 @@ export function processResourceBindings(spec, existing) {
83
84
  id: ids.name,
84
85
  name: 'name',
85
86
  type: 'string',
86
- resource: 'process',
87
+ resource,
87
88
  resourceKey,
88
89
  default: spec.name,
89
90
  propertyAttribute: 'name',
90
- resourceSubType: spec.resourceSubType,
91
+ ...(spec.resourceSubType !== undefined ? { resourceSubType: spec.resourceSubType } : {}),
91
92
  },
92
93
  {
93
94
  id: ids.folderPath,
94
95
  name: 'folderPath',
95
96
  type: 'string',
96
- resource: 'process',
97
+ resource,
97
98
  resourceKey,
98
99
  default: spec.folderPath,
99
100
  propertyAttribute: 'folderPath',
100
- resourceSubType: spec.resourceSubType,
101
+ ...(spec.resourceSubType !== undefined ? { resourceSubType: spec.resourceSubType } : {}),
101
102
  },
102
103
  ] };
103
104
  }
@@ -306,6 +306,7 @@ export declare const REFUSALS: {
306
306
  readonly HITL_APP_INCOMPLETE: "lint";
307
307
  readonly HITL_APP_INPUT_REQUIRED: "lint";
308
308
  readonly HITL_APP_INPUT_UNKNOWN: "lint";
309
+ readonly HITL_APP_LOCAL_HAS_FOLDER: "lint";
309
310
  readonly HITL_APP_MISSING: "lint";
310
311
  readonly HITL_APP_NO_SCHEMA: "lint";
311
312
  readonly HITL_APP_OUTCOME_UNKNOWN: "lint";
@@ -347,6 +347,7 @@ export const REFUSALS = {
347
347
  HITL_APP_INCOMPLETE: 'lint',
348
348
  HITL_APP_INPUT_REQUIRED: 'lint',
349
349
  HITL_APP_INPUT_UNKNOWN: 'lint',
350
+ HITL_APP_LOCAL_HAS_FOLDER: 'lint',
350
351
  HITL_APP_MISSING: 'lint',
351
352
  HITL_APP_NO_SCHEMA: 'lint',
352
353
  HITL_APP_OUTCOME_UNKNOWN: 'lint',
package/dist/merge.js CHANGED
@@ -454,6 +454,7 @@ function mergeFlowFindings(original, edited, options) {
454
454
  // not carried (#1133). ──
455
455
  result.bindings = mergeBindings(original.bindings, edited.bindings);
456
456
  result.bindings = withoutRowsMintedForKeptNodes(result.bindings, original, edited, options.baseline, keptFromOriginal, fieldwise);
457
+ result.bindings = withoutSupersededAppRows(result.bindings, originalNodes, allNodes);
457
458
  // metadata / solutionId / projectId / layout / runtime were carried by the
458
459
  // initial clone of `original` and are intentionally left untouched, apart
459
460
  // from the `layout.nodes` entries of removed nodes (dropped above) and added
@@ -667,6 +668,36 @@ function mergeBindings(orig, edited) {
667
668
  }
668
669
  return out;
669
670
  }
671
+ /**
672
+ * The `app` rows a human task's app resolves through: keyed by the app's `key`
673
+ * once it has a folder, by its `name` while it has none — flow-v1's rule
674
+ * (`hitl-serialization.ts`, #3910), and `appResourceBindings`'.
675
+ */
676
+ function appRowKey(node) {
677
+ const app = node.inputs?.app;
678
+ if (!app || typeof app !== 'object')
679
+ return undefined;
680
+ const k = app.folderPath ? app.key : app.name;
681
+ return typeof k === 'string' && k !== '' ? k : undefined;
682
+ }
683
+ /**
684
+ * Drop the original's `app` rows that a human task's app resolved through and
685
+ * that no merged node resolves through any more. That happens when the edit
686
+ * moves an app between the two keys — the canvas's `'solution_folder'` folder
687
+ * (keyed by `key`) re-emitted as an in-solution app (folder `''`, keyed by
688
+ * `name`): the edit brings the new pair, and the union would keep the old one
689
+ * beside it, orphaned (builder-sdk#1486 review). Rows no original human task
690
+ * used (an agent escalation's, say) are not touched.
691
+ */
692
+ function withoutSupersededAppRows(rows, originalNodes, mergedNodes) {
693
+ if (!rows)
694
+ return rows;
695
+ const keysOf = (nodes) => new Set(nodes.map(appRowKey).filter((k) => k !== undefined));
696
+ const before = keysOf(originalNodes);
697
+ const after = keysOf(mergedNodes);
698
+ return rows.filter((b) => !(b.resource === 'app' && typeof b.resourceKey === 'string'
699
+ && before.has(b.resourceKey) && !after.has(b.resourceKey)));
700
+ }
670
701
  /**
671
702
  * Drop each added row that the no-edit recompile minted only for nodes the merge
672
703
  * kept from the original (#1133). A row qualifies when the original lacks it, the