@revenexx/studio-integrations 2.8.0 → 2.9.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/ExpressionInput.vue +31 -0
  4. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +44 -11
  5. package/dist/runtime/components/Integrations/NodeConfigField.vue +5 -1
  6. package/dist/runtime/components/Integrations/NodePalette.vue +72 -64
  7. package/dist/runtime/components/Integrations/RunDetailPanel.vue +11 -5
  8. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +2 -2
  9. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +6 -0
  10. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -2
  11. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +6 -0
  12. package/dist/runtime/components/Integrations/WorkflowEditor.vue +147 -27
  13. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +36 -12
  14. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +14 -0
  15. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +162 -13
  16. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +14 -0
  17. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +34 -12
  18. package/dist/runtime/components/Integrations/WorkflowTriggerView.d.vue.ts +8 -1
  19. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +3 -1
  20. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue.d.ts +8 -1
  21. package/dist/runtime/components/Integrations/canvasSides.d.ts +27 -0
  22. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  23. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +4 -1
  24. package/dist/runtime/composables/useIntegrationsCredentials.js +8 -3
  25. package/dist/runtime/data/canvasShapes.d.ts +139 -0
  26. package/dist/runtime/data/canvasShapes.js +130 -0
  27. package/dist/runtime/data/connectionRules.d.ts +110 -0
  28. package/dist/runtime/data/connectionRules.js +110 -0
  29. package/dist/runtime/data/graphShapes.d.ts +175 -0
  30. package/dist/runtime/data/graphShapes.js +359 -0
  31. package/dist/runtime/data/handleId.d.ts +12 -0
  32. package/dist/runtime/data/handleId.js +3 -0
  33. package/dist/runtime/data/refusalMessage.d.ts +32 -0
  34. package/dist/runtime/data/refusalMessage.js +42 -0
  35. package/dist/runtime/data/saveRefusal.d.ts +31 -0
  36. package/dist/runtime/data/saveRefusal.js +25 -0
  37. package/dist/runtime/i18n/messages/de.json +36 -1
  38. package/dist/runtime/i18n/messages/en.json +36 -1
  39. package/dist/runtime/pages/integrations/schedules.vue +1 -1
  40. package/dist/runtime/pages/integrations/workflows/[id].vue +38 -3
  41. package/dist/runtime/types/integrations.d.ts +13 -4
  42. package/dist/runtime/utils/skippedStep.d.ts +39 -0
  43. package/dist/runtime/utils/skippedStep.js +22 -0
  44. package/package.json +2 -2
@@ -0,0 +1,139 @@
1
+ import type { WorkflowEdge, WorkflowNode, WorkflowTrigger } from '../types/integrations.js';
2
+ import type { ShapeRule } from './graphShapes.js';
3
+ /**
4
+ * What a canvas draws of a workflow's splits and merges (PO-750, rules in PO-746).
5
+ *
6
+ * Three things, all read from the analysis the save check runs (`graphShapes.ts`):
7
+ *
8
+ * - a **splitter** — an output port with more than one connection leaving it — is
9
+ * drawn as a box instead of a dot, on that port only;
10
+ * - a **merger** — an input whose connections come from the branches of one
11
+ * splitter — is drawn as a box instead of a dot. A converging input (the
12
+ * branches of a condition, several triggers) keeps its dot;
13
+ * - a **virtual merger** — each intermediate level the analysis needed to break a
14
+ * nested shape down — is a small box in front of the input, and the connections
15
+ * of that level arrive at it rather than at the input dot.
16
+ *
17
+ * **Drawn, never stored.** The virtual merger is a second connection dot on the
18
+ * node card that owns the input, placed relative to that input: auto-layout never
19
+ * sees it, nobody can select, drag or delete it, and the workflow keeps naming the
20
+ * node the connection arrives at, nothing else. A connection routed through it is
21
+ * the same connection with the same id — selecting, deleting, re-pointing and the
22
+ * replay's colouring all keep working, because none of them read which dot an end
23
+ * sits on. The other way to draw it, a node of its own on the canvas, would have
24
+ * had to be kept out of every path that walks the canvas's nodes: select-all,
25
+ * copy, delete, tidy up, the minimap, the fit.
26
+ *
27
+ * One function for all three canvases (editor, run replay, history preview), so
28
+ * that they cannot disagree about a workflow, and called per sync like
29
+ * {@link nodeLoopData}: the shapes are a function of the connections.
30
+ */
31
+ /**
32
+ * One branch arriving at an input, as its tooltip names it: the step it ends in,
33
+ * the output it leaves by, and how the receiving step reads what it hands on
34
+ * (rules of PO-746, the run-time half in PO-502).
35
+ */
36
+ export interface BranchLine {
37
+ /** The step's name, or the trigger's. */
38
+ label: string;
39
+ /**
40
+ * The output it leaves by — `null` for a trigger, which has only the one. Its
41
+ * label is looked up when the line is written (`branchLineText`), not here: the
42
+ * catalogue can arrive after the canvas first syncs, and a label read then would
43
+ * stay the bare port name.
44
+ */
45
+ port: {
46
+ nodeSlug: string;
47
+ nodeVersion: string;
48
+ output: string;
49
+ } | null;
50
+ /**
51
+ * Where the receiving step finds it in an expression: `in.<step id>` when the
52
+ * branch is gathered with others (a merger hands each one on by the step it
53
+ * ends in), plain `in` when it is the one alternative that arrived.
54
+ */
55
+ address: string;
56
+ }
57
+ /** The output as its dot names it — the manifest's label — with the port name beside it where that reads differently. */
58
+ export declare function portLabelText(port: NonNullable<BranchLine['port']>): string;
59
+ /** One branch as one tooltip line: `Price lines · Out → ${{ in.price }}`. */
60
+ export declare function branchLineText(b: BranchLine): string;
61
+ /** A level of a merge drawn in front of an input. */
62
+ export interface VirtualMergerMark {
63
+ /** The dot the connections of this level arrive at. */
64
+ handleId: string;
65
+ /** 1 directly in front of the input, 2 in front of that, and so on. */
66
+ level: number;
67
+ /**
68
+ * The virtual merger this one stands in front of, as an index into the same
69
+ * list — `null` on level 1, which stands in front of the input itself.
70
+ */
71
+ parent: number | null;
72
+ /** `merger`: every branch of one splitter (AND). `converging`: one of several (OR). */
73
+ kind: 'merger' | 'converging';
74
+ /**
75
+ * Where the box stands across from the input, in steps of one box width: `-1`
76
+ * one step left of what it feeds, `2` two steps right. Never `0` — a box straight
77
+ * in front of the input would sit on the connections that pass it by to arrive
78
+ * at the input itself.
79
+ */
80
+ offset: number;
81
+ /** The steps whose connections this level gathers, by the name the canvas shows. */
82
+ members: string[];
83
+ /** The same, by id — what decides which connection arrives here. */
84
+ memberIds: string[];
85
+ /** Each connection this level gathers, in the order the workflow stores them. */
86
+ branches: BranchLine[];
87
+ }
88
+ /** The rules of how branches come back together, which the canvas says as it is drawn. */
89
+ export type JoinRule = Extract<ShapeRule, 'merger-across-splitters' | 'splitter-bypassed' | 'undecomposable-shape'>;
90
+ /**
91
+ * An input the save will refuse because of how branches arrive at it (decided
92
+ * 19 Oct 2026), with the connection where one branch of a split crosses into
93
+ * another when the analysis found it — the one to change, rarely at this step.
94
+ */
95
+ export interface ShapeRefusal {
96
+ rule: JoinRule;
97
+ crossing?: {
98
+ edgeId: string;
99
+ /** The step the crossing leaves, as its card names it … */
100
+ from: string;
101
+ /** … by which output, as its dot names it … */
102
+ output: string;
103
+ /** … and the step it crosses into. */
104
+ to: string;
105
+ };
106
+ }
107
+ /** What one node card draws of the shapes. Absent from the map: nothing to draw. */
108
+ export interface NodeShapeData {
109
+ /** The output ports with more than one connection leaving them. */
110
+ splitPorts?: string[];
111
+ /** The input merges the branches of one splitter. */
112
+ merger?: boolean;
113
+ /** The input converges: several alternatives, of which the one that ran arrives. */
114
+ converging?: boolean;
115
+ /** On a merger or a converging input: every connection arriving, in stored order. */
116
+ inputBranches?: BranchLine[];
117
+ /** Levels drawn in front of the input, level 1 first. */
118
+ virtualMergers?: VirtualMergerMark[];
119
+ /** The input is refused by the rules for joining branches; it is then neither merger nor converging. */
120
+ refused?: ShapeRefusal;
121
+ }
122
+ export interface CanvasShapes {
123
+ byNode: Map<string, NodeShapeData>;
124
+ /** Trigger ids whose one output splits. */
125
+ splitTriggers: Set<string>;
126
+ /** Ids of the connections where branches of a split cross, to draw as the ones to change. */
127
+ crossingEdges: Set<string>;
128
+ /** Every refused input, in the analysis's order — what a change is compared by. */
129
+ refusals: (ShapeRefusal & {
130
+ nodeId: string;
131
+ })[];
132
+ /**
133
+ * The dot a connection arrives at when it is not the input dot: the deepest
134
+ * virtual merger in front of its target whose level gathers its source. `null`
135
+ * means the input dot, which is every connection but those.
136
+ */
137
+ arrivalHandle: (edge: Pick<WorkflowEdge, 'from' | 'to'>) => string | null;
138
+ }
139
+ export declare function canvasShapes(nodes: readonly WorkflowNode[], edges: readonly WorkflowEdge[], triggers?: readonly WorkflowTrigger[]): CanvasShapes;
@@ -0,0 +1,130 @@
1
+ import { nodeDisplayLabel } from "../utils/validationFindings.js";
2
+ import { resolveDisplayName, resolveLocalized } from "../utils/localize.js";
3
+ import { findManifest, triggerTypeLabel } from "./integrationNodeCatalog.js";
4
+ import { isTerminalManifest } from "./nodeInputData.js";
5
+ import { analyzeGraphShapes } from "./graphShapes.js";
6
+ import { iteratorNodeIds } from "./loopBody.js";
7
+ import { virtualMergerHandleId } from "./handleId.js";
8
+ export function portLabelText(port) {
9
+ const declared = findManifest(port.nodeSlug, port.nodeVersion)?.outputs?.find((o) => o.name === port.output);
10
+ const label = resolveLocalized(declared?.label) || port.output;
11
+ return label.toLowerCase() === port.output.toLowerCase() ? label : `${label} (${port.output})`;
12
+ }
13
+ export function branchLineText(b) {
14
+ return `${b.port ? `${b.label} \xB7 ${portLabelText(b.port)}` : b.label} \u2192 \${{ ${b.address} }}`;
15
+ }
16
+ const JOIN_RULES = /* @__PURE__ */ new Set(["merger-across-splitters", "splitter-bypassed", "undecomposable-shape"]);
17
+ function splittersOf(nodeIds, edges) {
18
+ const count = /* @__PURE__ */ new Map();
19
+ for (const e of edges) {
20
+ if (!nodeIds.has(e.to.nodeId)) continue;
21
+ const key = `${e.from.nodeId}\0${e.from.output}`;
22
+ const entry = count.get(key) ?? { nodeId: e.from.nodeId, port: e.from.output, n: 0 };
23
+ entry.n += 1;
24
+ count.set(key, entry);
25
+ }
26
+ return [...count.values()].filter((p) => p.n > 1).map(({ nodeId, port }) => ({ nodeId, port }));
27
+ }
28
+ export function canvasShapes(nodes, edges, triggers = []) {
29
+ const byNode = /* @__PURE__ */ new Map();
30
+ const splitTriggers = /* @__PURE__ */ new Set();
31
+ const nodeIds = new Set(nodes.map((n) => n.id));
32
+ const entry = (id) => {
33
+ const found = byNode.get(id);
34
+ if (found) return found;
35
+ const fresh = {};
36
+ byNode.set(id, fresh);
37
+ return fresh;
38
+ };
39
+ const terminals = nodes.filter((n) => isTerminalManifest(findManifest(n.nodeSlug, n.nodeVersion))).map((n) => n.id);
40
+ const shapes = analyzeGraphShapes({ nodes: [...nodes], edges: [...edges] }, { iterators: iteratorNodeIds(nodes), terminals });
41
+ for (const { nodeId, port } of splittersOf(nodeIds, edges)) {
42
+ if (nodeIds.has(nodeId)) (entry(nodeId).splitPorts ??= []).push(port);
43
+ else splitTriggers.add(nodeId);
44
+ }
45
+ for (const merger of shapes.mergers) entry(merger.nodeId).merger = true;
46
+ for (const id of shapes.converging) entry(id).converging = true;
47
+ const labelOf = new Map([
48
+ ...triggers.map((t) => [t.id, resolveDisplayName(t.name) || triggerTypeLabel(t.type)]),
49
+ ...nodes.map((n) => [n.id, nodeDisplayLabel(n)])
50
+ ]);
51
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
52
+ const portOf = (nodeId, output) => {
53
+ const n = nodeById.get(nodeId);
54
+ return n ? { nodeSlug: n.nodeSlug, nodeVersion: n.nodeVersion, output } : null;
55
+ };
56
+ const keyedAt = /* @__PURE__ */ new Map();
57
+ for (const vm of shapes.virtualMergers) {
58
+ if (vm.kind !== "merger") continue;
59
+ const keyed = keyedAt.get(vm.input) ?? /* @__PURE__ */ new Set();
60
+ vm.members.forEach((m) => keyed.add(m));
61
+ keyedAt.set(vm.input, keyed);
62
+ }
63
+ const mergerIds = new Set(shapes.mergers.map((m) => m.nodeId));
64
+ const branchesInto = (input, sources) => edges.filter((e) => e.to.nodeId === input && (!sources || sources.includes(e.from.nodeId))).map((e) => ({
65
+ label: labelOf.get(e.from.nodeId) ?? e.from.nodeId,
66
+ port: portOf(e.from.nodeId, e.from.output),
67
+ address: mergerIds.has(input) || keyedAt.get(input)?.has(e.from.nodeId) ? `in.${e.from.nodeId}` : "in"
68
+ }));
69
+ for (const id of [...mergerIds, ...shapes.converging]) entry(id).inputBranches = branchesInto(id);
70
+ const xOf = new Map(nodes.filter((n) => n.position).map((n) => [n.id, n.position.x]));
71
+ const meanX = (ids) => {
72
+ const xs = ids.map((id) => xOf.get(id)).filter((x) => x !== void 0);
73
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
74
+ };
75
+ for (const vm of shapes.virtualMergers) {
76
+ const list = entry(vm.input).virtualMergers ??= [];
77
+ const found = list.findIndex((p) => p.level === vm.level - 1 && vm.members.every((m) => p.memberIds.includes(m)));
78
+ const parent = found < 0 ? null : found;
79
+ const around = parent === null ? edges.filter((e) => e.to.nodeId === vm.input).map((e) => e.from.nodeId) : list[parent].memberIds;
80
+ const mine = meanX(vm.members);
81
+ const rest = meanX(around.filter((id) => !vm.members.includes(id)));
82
+ const side = mine !== null && rest !== null && mine > rest ? 1 : -1;
83
+ const taken = list.filter((o) => o.parent === parent && Math.sign(o.offset - (parent === null ? 0 : list[parent].offset)) === side).length;
84
+ list.push({
85
+ handleId: virtualMergerHandleId(list.length + 1, "top"),
86
+ level: vm.level,
87
+ parent,
88
+ offset: (parent === null ? 0 : list[parent].offset) + side * (1 + taken),
89
+ kind: vm.kind,
90
+ members: vm.members.map((id) => labelOf.get(id) ?? id),
91
+ memberIds: vm.members,
92
+ branches: branchesInto(vm.input, vm.members)
93
+ });
94
+ }
95
+ const arrivalHandle = (edge) => {
96
+ const levels = byNode.get(edge.to.nodeId)?.virtualMergers;
97
+ if (!levels) return null;
98
+ let deepest = null;
99
+ for (const vm of levels) {
100
+ if (vm.memberIds.includes(edge.from.nodeId) && (!deepest || vm.level > deepest.level)) deepest = vm;
101
+ }
102
+ return deepest?.handleId ?? null;
103
+ };
104
+ const outputLabel = (nodeId, output) => {
105
+ const n = nodeById.get(nodeId);
106
+ const declared = n ? findManifest(n.nodeSlug, n.nodeVersion)?.outputs?.find((o) => o.name === output) : void 0;
107
+ return resolveLocalized(declared?.label) || output;
108
+ };
109
+ const crossingEdges = /* @__PURE__ */ new Set();
110
+ const refusals = [];
111
+ for (const v of shapes.violations) {
112
+ if (!JOIN_RULES.has(v.rule) || !nodeIds.has(v.nodeId)) continue;
113
+ const e = v.edgeIndex === void 0 ? void 0 : edges[v.edgeIndex];
114
+ const refused = {
115
+ rule: v.rule,
116
+ ...e ? {
117
+ crossing: {
118
+ edgeId: e.id,
119
+ from: labelOf.get(e.from.nodeId) ?? e.from.nodeId,
120
+ output: outputLabel(e.from.nodeId, e.from.output),
121
+ to: labelOf.get(e.to.nodeId) ?? e.to.nodeId
122
+ }
123
+ } : {}
124
+ };
125
+ if (e) crossingEdges.add(e.id);
126
+ entry(v.nodeId).refused = refused;
127
+ refusals.push({ nodeId: v.nodeId, ...refused });
128
+ }
129
+ return { byNode, splitTriggers, crossingEdges, refusals, arrivalHandle };
130
+ }
@@ -0,0 +1,110 @@
1
+ import type { WorkflowEdge, WorkflowNode } from '../types/integrations.js';
2
+ /**
3
+ * Which connections the canvas refuses while they are being drawn (PO-750, rule 11
4
+ * of PO-746).
5
+ *
6
+ * **Only one that no added connection can make valid** — only removing one that
7
+ * already stands could:
8
+ *
9
+ * - one that **closes a cycle** — adding connections never takes a cycle out;
10
+ * - one that **crosses a loop's boundary for good** — a connection into a loop's
11
+ * body from a step that can never join that body, or out of the body into a step
12
+ * the rest of the workflow already reaches. Only `item` leads in and only `done`
13
+ * leads out.
14
+ *
15
+ * Everything else — a merger across two splitters, a branch that bypasses its
16
+ * splitter, a shape that does not break down, a reference to a sibling branch, and
17
+ * a crossing the author can still close by drawing more — is left to the save,
18
+ * which names what is wrong. Refusing more while drawing would make the verdict
19
+ * depend on the order things are drawn in, because a shape is often invalid halfway
20
+ * through being drawn.
21
+ *
22
+ * **When a crossing can still be closed.** A loop's body is everything its Item
23
+ * output reaches — the definition the save check's `graphShapes.ts` uses. A
24
+ * connection from a step outside it into it crosses; but if that step is fed by
25
+ * nothing, connecting the body to it pulls it in, and the crossing is gone. In
26
+ * general a crossing can be closed by additions alone exactly when everything that
27
+ * would have to join the body with its source — the source, what feeds it and what
28
+ * it leads to, outward until the body is met — holds neither a trigger nor the loop
29
+ * itself. That is a property of the graph, not of the order its connections were
30
+ * drawn in: `x → body` drawn before `body → x` or after it ends in the same verdict.
31
+ *
32
+ * Only a violation the change *adds* refuses it. A workflow that already carries
33
+ * one — saved before the rules existed — can still be edited everywhere else, and a
34
+ * cycle already in it does not stop the loop boundaries from being judged.
35
+ */
36
+ export type NeverValid = 'cycle' | 'loop-boundary';
37
+ interface Graph {
38
+ nodes: readonly Pick<WorkflowNode, 'id'>[];
39
+ edges: readonly WorkflowEdge[];
40
+ }
41
+ /**
42
+ * The ids of the connections crossing a loop's boundary that no added connection
43
+ * can close. Exported for the writers that place several connections at once
44
+ * (duplicate, paste), which judge the finished set rather than each in turn.
45
+ */
46
+ export declare function lastingCrossings(graph: Graph, iterators: readonly string[]): Set<string>;
47
+ /**
48
+ * Why a change was refused, with what the explanation names (PO-750): the steps a
49
+ * cycle would run through, or the loop whose boundary is crossed — and how: into
50
+ * its steps from outside, out of them into a step something else reaches as well,
51
+ * or from its own Item output to a step something outside reaches.
52
+ */
53
+ export type Refusal = {
54
+ reason: 'cycle';
55
+ from?: string;
56
+ to?: string;
57
+ } | {
58
+ reason: 'loop-boundary';
59
+ iterator: string;
60
+ direction: 'into';
61
+ inside: string;
62
+ } | {
63
+ reason: 'loop-boundary';
64
+ iterator: string;
65
+ direction: 'out';
66
+ inside: string;
67
+ outside: string;
68
+ } | {
69
+ reason: 'loop-boundary';
70
+ iterator: string;
71
+ direction: 'item';
72
+ outside: string;
73
+ };
74
+ /** {@link neverValidChange}, with what the explanation names. */
75
+ export declare function refusalOfChange(before: Graph, after: Graph, iterators: readonly string[]): Refusal | null;
76
+ /**
77
+ * What a change to the workflow's connections adds that no added connection can
78
+ * make valid, or `null` when it adds nothing. `before` and `after` are the same
79
+ * workflow; connections are told apart by id.
80
+ */
81
+ export declare function neverValidChange(before: Graph, after: Graph, iterators: readonly string[]): NeverValid | null;
82
+ export type Candidate = {
83
+ from: {
84
+ nodeId: string;
85
+ output: string;
86
+ };
87
+ to: {
88
+ nodeId: string;
89
+ };
90
+ };
91
+ /**
92
+ * A judge for the connections drawn in one gesture. The workflow as it stands does
93
+ * not change while a connection is dragged, so what is known about it — who reaches
94
+ * whom, which crossings already stand — is worked out once, here, and each dot the
95
+ * drag passes only adds its one connection (review of #297: Vue Flow asks on every
96
+ * pointer move near a dot).
97
+ *
98
+ * `replacing` names a connection the new one takes the place of: an end of an
99
+ * existing connection being re-pointed. It is judged as gone, so the connection
100
+ * does not stand in its own way.
101
+ */
102
+ export declare function refusalJudge(graph: Graph, iterators: readonly string[], replacing?: string | null): (candidate: Candidate) => Refusal | null;
103
+ /**
104
+ * Why the connection from `from` to `to` can never become valid by drawing more,
105
+ * with what the explanation names — or `null` when it may be drawn.
106
+ */
107
+ export declare function connectionRefusal(graph: Graph, iterators: readonly string[], candidate: Candidate, replacing?: string | null): Refusal | null;
108
+ /** {@link connectionRefusal}, the reason alone. */
109
+ export declare function connectionNeverValid(graph: Graph, iterators: readonly string[], candidate: Candidate, replacing?: string | null): NeverValid | null;
110
+ export {};
@@ -0,0 +1,110 @@
1
+ import { ITEM_PORT } from "./loopBody.js";
2
+ function adjacency(graph) {
3
+ const nodeIds = new Set(graph.nodes.map((n) => n.id));
4
+ const out = /* @__PURE__ */ new Map();
5
+ const into = /* @__PURE__ */ new Map();
6
+ const add = (map, key, e) => {
7
+ const list = map.get(key);
8
+ if (list) list.push(e);
9
+ else map.set(key, [e]);
10
+ };
11
+ for (const e of graph.edges) {
12
+ if (!nodeIds.has(e.to.nodeId)) continue;
13
+ add(out, e.from.nodeId, e);
14
+ add(into, e.to.nodeId, e);
15
+ }
16
+ return { nodeIds, out, into };
17
+ }
18
+ function reach(adj, start) {
19
+ const seen = /* @__PURE__ */ new Set();
20
+ const queue = [...start];
21
+ for (let i = 0; i < queue.length; i++) {
22
+ const id = queue[i];
23
+ if (seen.has(id)) continue;
24
+ seen.add(id);
25
+ for (const e of adj.out.get(id) ?? []) queue.push(e.to.nodeId);
26
+ }
27
+ return seen;
28
+ }
29
+ function closesCycle(adj, from, to) {
30
+ return from === to || reach(adj, [to]).has(from);
31
+ }
32
+ function closable(adj, iterator, body, source) {
33
+ const seen = /* @__PURE__ */ new Set();
34
+ const queue = [source];
35
+ for (let i = 0; i < queue.length; i++) {
36
+ const id = queue[i];
37
+ if (seen.has(id) || body.has(id)) continue;
38
+ if (id === iterator || !adj.nodeIds.has(id)) return false;
39
+ seen.add(id);
40
+ for (const e of adj.into.get(id) ?? []) queue.push(e.from.nodeId);
41
+ for (const e of adj.out.get(id) ?? []) queue.push(e.to.nodeId);
42
+ }
43
+ return true;
44
+ }
45
+ function crossingsOf(adj, iterators) {
46
+ const lasting = /* @__PURE__ */ new Map();
47
+ for (const it of iterators) {
48
+ if (!adj.nodeIds.has(it)) continue;
49
+ const items = (adj.out.get(it) ?? []).filter((e) => e.from.output === ITEM_PORT);
50
+ const body = reach(adj, items.map((e) => e.to.nodeId));
51
+ if (body.has(it)) continue;
52
+ for (const id of body) {
53
+ for (const e of adj.into.get(id) ?? []) {
54
+ if (body.has(e.from.nodeId)) continue;
55
+ if (e.from.nodeId === it && e.from.output === ITEM_PORT) continue;
56
+ if (!closable(adj, it, body, e.from.nodeId) && !lasting.has(e.id)) lasting.set(e.id, { edge: e, iterator: it, body });
57
+ }
58
+ }
59
+ }
60
+ return lasting;
61
+ }
62
+ export function lastingCrossings(graph, iterators) {
63
+ return new Set(crossingsOf(adjacency(graph), iterators).keys());
64
+ }
65
+ export function refusalOfChange(before, after, iterators) {
66
+ const was = new Set(before.edges.map((e) => e.id));
67
+ const adjAfter = adjacency(after);
68
+ for (const e of after.edges) {
69
+ if (was.has(e.id) || !adjAfter.nodeIds.has(e.to.nodeId)) continue;
70
+ const without = adjacency({ nodes: after.nodes, edges: after.edges.filter((x) => x.id !== e.id) });
71
+ if (closesCycle(without, e.from.nodeId, e.to.nodeId)) return { reason: "cycle" };
72
+ }
73
+ const crossed = lastingCrossings(before, iterators);
74
+ const added = [...crossingsOf(adjAfter, iterators).values()].find((c) => !crossed.has(c.edge.id));
75
+ return added ? { reason: "loop-boundary", iterator: added.iterator, direction: "into", inside: added.edge.to.nodeId } : null;
76
+ }
77
+ export function neverValidChange(before, after, iterators) {
78
+ return refusalOfChange(before, after, iterators)?.reason ?? null;
79
+ }
80
+ const CANDIDATE_ID = "\0candidate";
81
+ export function refusalJudge(graph, iterators, replacing) {
82
+ const kept = replacing ? graph.edges.filter((e) => e.id !== replacing) : graph.edges;
83
+ const adjBefore = adjacency({ nodes: graph.nodes, edges: kept });
84
+ const crossed = new Set(crossingsOf(adjBefore, iterators).keys());
85
+ return (candidate) => {
86
+ const ends = { from: candidate.from.nodeId, to: candidate.to.nodeId };
87
+ if (closesCycle(adjBefore, ends.from, ends.to)) return { reason: "cycle", ...ends };
88
+ const adjAfter = adjacency({
89
+ nodes: graph.nodes,
90
+ edges: [...kept, { id: CANDIDATE_ID, from: candidate.from, to: { nodeId: ends.to } }]
91
+ });
92
+ const crossings = [...crossingsOf(adjAfter, iterators).values()];
93
+ const added = crossings.find((c) => !crossed.has(c.edge.id));
94
+ if (!added) return null;
95
+ const { iterator } = added;
96
+ if (ends.from === iterator && candidate.from.output === ITEM_PORT) {
97
+ return { reason: "loop-boundary", iterator, direction: "item", outside: ends.to };
98
+ }
99
+ if (added.body.has(ends.from)) {
100
+ return { reason: "loop-boundary", iterator, direction: "out", inside: ends.from, outside: ends.to };
101
+ }
102
+ return { reason: "loop-boundary", iterator, direction: "into", inside: added.edge.to.nodeId };
103
+ };
104
+ }
105
+ export function connectionRefusal(graph, iterators, candidate, replacing) {
106
+ return refusalJudge(graph, iterators, replacing)(candidate);
107
+ }
108
+ export function connectionNeverValid(graph, iterators, candidate, replacing) {
109
+ return connectionRefusal(graph, iterators, candidate, replacing)?.reason ?? null;
110
+ }
@@ -0,0 +1,175 @@
1
+ import type { WorkflowEdge, WorkflowNode } from '../types/integrations.js';
2
+ /**
3
+ * The shapes a workflow graph's joins take (PO-748, rules decided in PO-746):
4
+ * which output ports split, which inputs merge the branches of one split, which
5
+ * inputs converge OR-branches, which loop bodies are closed, and which shapes
6
+ * are refused. Held to `graphShapes.cases.json`, the table the API's save-time
7
+ * check and the studio canvas port this algorithm against.
8
+ *
9
+ * An input is a node's input name (`to.input`, `in` when absent). How an input
10
+ * with several connections is judged: the graph gets a virtual ROOT above every
11
+ * origin — each trigger handle, and each node nothing feeds — so that every
12
+ * vertex has dominators. For the input's connections ("branches"):
13
+ *
14
+ * 1. Find the nearest common dominator `n` of the branches' source vertices:
15
+ * the deepest vertex every path to every branch passes through. "Same
16
+ * splitter" is judged by dominance (decided 15 Oct 2026): a branch that can
17
+ * also be reached around a splitter does not belong to it.
18
+ * 2. For each branch, collect the connections leaving `n` it can be reached
19
+ * through, and group branches that share one. One group only means the
20
+ * branches do not leave `n` each via their own connection. Through a single
21
+ * port of `n` that is `merger-across-splitters`; through several ports (or
22
+ * origins), some branch reaches the input around the splitter the others
23
+ * share — `splitter-bypassed`, naming that splitter and branch.
24
+ * 3. A group reached through two ports of `n` mixes "always" with "only if
25
+ * this port fired" — `undecomposable-shape`.
26
+ * 4. All groups on one port: an AND level, a merger at that splitter port.
27
+ * Groups on several ports: an OR level, a converging input; ports carrying
28
+ * several groups become an AND level beneath it. ROOT's ports are its
29
+ * origins, so several triggers always converge.
30
+ * 5. A group of several branches is decomposed again from step 1. Every level
31
+ * between the input and its top level is a virtual merger the canvas
32
+ * draws in front of the input (render-only, never stored or run).
33
+ *
34
+ * Every part is decomposed even after one is refused, and the input reports
35
+ * one refusal, picked by precedence: `splitter-bypassed`, then
36
+ * `merger-across-splitters`, then `undecomposable-shape`, ties broken by the
37
+ * named splitter and branch. So the verdict cannot depend on edge order.
38
+ *
39
+ * Mixed AND/OR at one input (cond.true → a and b, cond.false → d, all into m)
40
+ * stays a converging input with an AND level in front of it (decided 15 Oct
41
+ * 2026). At run time it hands on whatever arrived — the inner merger's keyed
42
+ * object `{a, b}`, or d's output as it is — so its `in.x` is not checked:
43
+ * `merger-in-reference` applies to inputs whose top level is a merger.
44
+ *
45
+ * A loop body ends in exactly one step (`loop-end-not-unique` otherwise). A
46
+ * terminal step — one whose manifest declares no output ports at all, such as
47
+ * `revenexx:stop-and-error` — ends the run, not the body, so it is no body end
48
+ * (decided 15 Oct 2026): it stays a body member, a connection into it does not
49
+ * keep its source from being the end, and it is never the end itself. That is
50
+ * what lets a body route each step's error to its own stop step.
51
+ *
52
+ * Pure and deterministic: no clock, randomness or IO, and every list it returns
53
+ * is sorted by Unicode code point (see {@link analyzeGraphShapes}).
54
+ *
55
+ * **A port, not a second design (PO-750).** This file is the worker's
56
+ * `worker/src/graphShapes.ts` in revenexx/integrations (PR #357, PO-748), taken
57
+ * over almost verbatim: the canvas draws splitters and mergers from it, and the
58
+ * drag refuses a connection from it (`connectionRules.ts`), so what the canvas
59
+ * shows must be what the save check decides. The two are held together by one
60
+ * table, `graphShapes.cases.json`, which `graphShapes.test.ts` runs here from a
61
+ * byte-identical copy. A change to the algorithm goes to the worker first and
62
+ * comes back here with the table — never the other way round. The only edits:
63
+ * the import of the blob types, this paragraph, the repository's code style, and
64
+ * the non-null assertions this repository's stricter index checking asks for.
65
+ * No Vue import, so the unit suite reaches it.
66
+ */
67
+ export interface ShapePort {
68
+ nodeId: string;
69
+ port: string;
70
+ }
71
+ export interface MergerBranch {
72
+ splitter: ShapePort;
73
+ /** The node whose output the merger receives as `in.<lastNodeId>` — a trigger handle when the branch is the trigger's own connection. */
74
+ lastNodeId: string;
75
+ }
76
+ export interface ShapeMerger {
77
+ nodeId: string;
78
+ /** Only when the node's connections name more than one input. */
79
+ inputName?: string;
80
+ branches: MergerBranch[];
81
+ }
82
+ export interface VirtualMerger {
83
+ /** The node the level is drawn in front of. */
84
+ input: string;
85
+ /** Only when the node's connections name more than one input. */
86
+ inputName?: string;
87
+ /** 1 is the level directly in front of the input, 2 the one in front of that, and so on. */
88
+ level: number;
89
+ kind: 'merger' | 'converging';
90
+ /** Only on a `merger` level. */
91
+ splitter?: ShapePort;
92
+ /** The lastNodeIds of the input's connections this level gathers. */
93
+ members: string[];
94
+ }
95
+ export interface LoopBody {
96
+ members: string[];
97
+ /** Absent when the body has no node, or more than one end. */
98
+ endNode?: string;
99
+ }
100
+ export type ShapeRule = 'merger-across-splitters' | 'splitter-bypassed' | 'undecomposable-shape' | 'sibling-reference' | 'loop-boundary' | 'loop-end-not-unique' | 'merger-in-reference' | 'cycle';
101
+ export interface ShapeViolation {
102
+ rule: ShapeRule;
103
+ nodeId: string;
104
+ /** Shape rules, only when the node's connections name more than one input. */
105
+ inputName?: string;
106
+ /**
107
+ * The index in `blob.edges` of the connection that crosses: a loop body's
108
+ * boundary (`loop-boundary`), or from one branch of a split into another
109
+ * (`merger-across-splitters`, where one is found).
110
+ */
111
+ edgeIndex?: number;
112
+ /** Reference rules: the node id (`nodes.`) or key (`in.`) referenced. */
113
+ target?: string;
114
+ /** `splitter-bypassed`: the splitter the other branches pass … */
115
+ splitter?: ShapePort;
116
+ /** … and the lastNodeId of the branch that reaches the input around it. */
117
+ branch?: string;
118
+ }
119
+ /** An `${{ nodes.<target>… }}` or `${{ in.<target>… }}` in a node's settings. */
120
+ export interface ShapeReference {
121
+ fromNodeId: string;
122
+ root: 'nodes' | 'in';
123
+ target: string;
124
+ }
125
+ export interface GraphShapes {
126
+ splitters: ShapePort[];
127
+ mergers: ShapeMerger[];
128
+ /** Nodes with at least one converging input. */
129
+ converging: string[];
130
+ virtualMergers: VirtualMerger[];
131
+ loopBodies: Record<string, LoopBody>;
132
+ violations: ShapeViolation[];
133
+ }
134
+ export interface GraphShapeAnalysis extends GraphShapes {
135
+ /**
136
+ * The merger's branches, or `null` when that input is not a merger. Without
137
+ * `inputName`, the node's first merging input in code-point order.
138
+ */
139
+ mergerBranches(nodeId: string, inputName?: string): MergerBranch[] | null;
140
+ /** The loop body's one end node, or `null` when it has none or several. */
141
+ loopEndNode(iteratorId: string): string | null;
142
+ /** Whether `targetNodeId` lies in a sibling branch of a splitter `fromNodeId` is in (rule 9). */
143
+ isSiblingReference(fromNodeId: string, targetNodeId: string): boolean;
144
+ }
145
+ export interface GraphShapeOptions {
146
+ /**
147
+ * Output ports per node, as its manifest declares them. Informational: the
148
+ * shapes are read from the ports the connections name, and a declared port
149
+ * nothing is wired to splits nothing and converges nothing.
150
+ */
151
+ ports?: Record<string, string[]>;
152
+ /** Node ids of iterator nodes (ports `item` and `done`). */
153
+ iterators: string[];
154
+ /** Node ids of terminal steps (no declared output ports): never a loop body's end. */
155
+ terminals?: string[];
156
+ references?: ShapeReference[];
157
+ }
158
+ export interface GraphShapeBlob {
159
+ nodes: Pick<WorkflowNode, 'id'>[];
160
+ edges: Pick<WorkflowEdge, 'from' | 'to'>[];
161
+ }
162
+ /**
163
+ * Analyses the shapes of `blob`. Sorting, so the result compares as plain data:
164
+ * splitters by nodeId then port; mergers by nodeId, inputName, their branches
165
+ * by lastNodeId then splitter; converging ids; virtual mergers by input,
166
+ * inputName, level, then members; loop members; violations by rule, nodeId,
167
+ * inputName, edgeIndex, target, branch. Optional fields are omitted, never null.
168
+ *
169
+ * A connection from an id that is no node is read as a trigger handle — the
170
+ * save-time check reports dangling ids on its own. A graph with a cycle is
171
+ * reported as one `cycle` violation per node on a cycle and nothing else —
172
+ * dominance is undefined there. An input receiving a connection across a loop
173
+ * boundary is not classified further: the boundary is the error.
174
+ */
175
+ export declare function analyzeGraphShapes(blob: GraphShapeBlob, opts: GraphShapeOptions): GraphShapeAnalysis;