@revenexx/studio-integrations 2.8.1 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/module.json +2 -2
- package/dist/runtime/components/Integrations/ExpressionInput.vue +31 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +5 -1
- package/dist/runtime/components/Integrations/NodePalette.vue +75 -71
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +11 -5
- package/dist/runtime/components/Integrations/TemplateInstallStep.vue +2 -2
- package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +6 -0
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -2
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +6 -0
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +147 -27
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +36 -12
- package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +14 -0
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +170 -13
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +14 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +34 -12
- package/dist/runtime/components/Integrations/WorkflowTriggerView.d.vue.ts +8 -1
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +3 -1
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue.d.ts +8 -1
- package/dist/runtime/components/Integrations/canvasSides.d.ts +27 -0
- package/dist/runtime/components/Integrations/canvasSides.js +22 -0
- package/dist/runtime/composables/useIntegrationsCredentials.d.ts +4 -1
- package/dist/runtime/composables/useIntegrationsCredentials.js +8 -3
- package/dist/runtime/data/canvasShapes.d.ts +139 -0
- package/dist/runtime/data/canvasShapes.js +130 -0
- package/dist/runtime/data/connectionRules.d.ts +110 -0
- package/dist/runtime/data/connectionRules.js +110 -0
- package/dist/runtime/data/graphShapes.d.ts +175 -0
- package/dist/runtime/data/graphShapes.js +359 -0
- package/dist/runtime/data/handleId.d.ts +12 -0
- package/dist/runtime/data/handleId.js +3 -0
- package/dist/runtime/data/integrationTemplates.js +3 -4
- package/dist/runtime/data/refusalMessage.d.ts +32 -0
- package/dist/runtime/data/refusalMessage.js +42 -0
- package/dist/runtime/data/saveRefusal.d.ts +31 -0
- package/dist/runtime/data/saveRefusal.js +25 -0
- package/dist/runtime/i18n/messages/de.json +36 -1
- package/dist/runtime/i18n/messages/en.json +36 -1
- package/dist/runtime/pages/integrations/schedules.vue +1 -1
- package/dist/runtime/pages/integrations/workflows/[id].vue +38 -3
- package/dist/runtime/types/integrations.d.ts +13 -4
- package/dist/runtime/utils/apiError.js +7 -4
- package/dist/runtime/utils/packageLabel.d.ts +17 -0
- package/dist/runtime/utils/packageLabel.js +6 -0
- package/dist/runtime/utils/skippedStep.d.ts +39 -0
- package/dist/runtime/utils/skippedStep.js +22 -0
- package/package.json +3 -3
- package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +0 -21
- package/dist/runtime/components/Integrations/NodeLogo.vue +0 -10
- package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +0 -21
|
@@ -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;
|