flownav 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +90 -2
- package/dist/assemble.js +127 -0
- package/dist/build.js +344 -0
- package/dist/checks.js +183 -0
- package/dist/cli.js +242 -0
- package/dist/diagram.js +123 -0
- package/dist/diff.js +40 -0
- package/dist/expressions.js +184 -0
- package/dist/graph.js +133 -0
- package/dist/install.js +47 -0
- package/dist/mcp.js +81 -0
- package/dist/overview.js +68 -0
- package/dist/queries.js +285 -0
- package/dist/report.js +99 -0
- package/dist/source.js +146 -0
- package/dist/store.js +34 -0
- package/dist/types.js +6 -0
- package/dist/util.js +68 -0
- package/dist/viewer.js +425 -0
- package/package.json +44 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omer Turkenits
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# flownav
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A typed node graph for n8n workflows, so an AI agent can navigate a workflow without reading raw JSON.
|
|
4
|
+
Alias command: `fnav`. Read-only against n8n.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install && npm run build
|
|
8
|
+
node dist/cli.js build ./examples # -> .flownav/graph.json + WORKFLOW_REPORT.md
|
|
9
|
+
node dist/cli.js explain "Reply"
|
|
10
|
+
node dist/cli.js affected "Normalize" --rename
|
|
11
|
+
node dist/cli.js check
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What it models
|
|
15
|
+
|
|
16
|
+
Edges point in the direction of data or control flow and carry `kind`, optional `label`, optional `fields`, and a `confidence`.
|
|
17
|
+
|
|
18
|
+
| kind | meaning | confidence |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `main` | connection; labels `true`/`false`, `case N` or the rule's output name, `loop`/`done`, `error` | EXPLICIT |
|
|
21
|
+
| `capability` | `ai_*` sub-node (model, tool, memory) into its agent; shown as an agent card | EXPLICIT |
|
|
22
|
+
| `reads` | an expression or Code node reads another node, with field names (`$('X')`, `$node["X"]`, `$items("X")`) | EXTRACTED |
|
|
23
|
+
| `calls` | Execute Workflow / tool workflow into the callee's trigger (`label: tool` for agent tools) | EXTRACTED by ID, INFERRED by name |
|
|
24
|
+
| `on_error` | `settings.errorWorkflow` | EXTRACTED |
|
|
25
|
+
| `webhook_calls` | HTTP Request to `/webhook/<path>` matched to a Webhook node | INFERRED |
|
|
26
|
+
| `responds_via` | Webhook to a reachable Respond to Webhook | EXTRACTED |
|
|
27
|
+
| `uses` | node to credential (by id; no secrets) | EXPLICIT |
|
|
28
|
+
| `reads_resource` / `writes_resource` / `uses_resource` | same sheet, table, channel, `$env`/`$vars` across nodes | INFERRED / EXTRACTED |
|
|
29
|
+
|
|
30
|
+
In Code nodes, `$(n)` where `n` iterates a literal list (`for (const n of ["A","B"])`, `.forEach`, `const n = "A"`) yields INFERRED `reads` edges to each name. Other dynamic references (computed node names, runtime workflow IDs, embedded or URL sub-workflows) are recorded as *unresolved* and shown in the report. They are never guessed.
|
|
31
|
+
Sticky notes are not nodes; the nodes inside a sticky's rectangle get its title as their group.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
flownav build <path> files -> graph + report (--out dir, --no-report)
|
|
37
|
+
flownav sync [--profile p | --url u] live n8n, incremental (--active --tags --name --project --executions)
|
|
38
|
+
flownav status [--check] what is loaded; --check compares with the instance
|
|
39
|
+
flownav profile add|list|remove base URL + the NAME of the env var holding the key
|
|
40
|
+
flownav report | scan
|
|
41
|
+
flownav explain <node>
|
|
42
|
+
flownav neighbors <node> [--depth N --dir in|out|both --kinds a,b]
|
|
43
|
+
flownav path <A> <B> [--max N --kinds a,b]
|
|
44
|
+
flownav find [--type --cred --text --resource --workflow --disabled --trigger]
|
|
45
|
+
flownav affected <node> --rename|--delete|--schema|--behavior
|
|
46
|
+
flownav callers <workflow> | calls <workflow> | uses --cred <c>
|
|
47
|
+
flownav check [--only fragile-reads|dead-refs|cycles|unreachable|dead-ends|no-error-handler|webhook-no-respond]
|
|
48
|
+
flownav view [workflow] [--match text --collapse --focus <node> --depth N --out file.html --open] interactive offline HTML graph
|
|
49
|
+
flownav diagram [workflow] [--match text --collapse --focus <node> --depth N --no-reads --dir LR|TD --out file.md] Mermaid flowchart
|
|
50
|
+
flownav diff old.json new.json
|
|
51
|
+
flownav install claude [--global] [--mcp] writes the skill (and .mcp.json entry)
|
|
52
|
+
flownav serve MCP server on stdio
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Global: `--graph <file>`, `--format text|json`. Nodes are addressed as `Name` or `workflow:Name` (workflow id or name); ambiguous names list their candidates. `check` exits 1 when it finds an `error`.
|
|
56
|
+
|
|
57
|
+
### The rename gap
|
|
58
|
+
|
|
59
|
+
n8n rewrites connections when you rename a node but not `$('Old Name')` inside other nodes' expressions or Code. `affected --rename` lists exactly those nodes and parameters, and `check` reports references to nodes that no longer exist (`dead-refs`) and reads from nodes that may not have run (`fragile-reads`).
|
|
60
|
+
|
|
61
|
+
## Live n8n
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
export N8N_API_KEY=... # never written to disk or logs
|
|
65
|
+
flownav profile add prod --url https://n8n.example.com
|
|
66
|
+
flownav sync --profile prod --executions
|
|
67
|
+
flownav status --check
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Only `GET /api/v1/...` is ever issued (`X-N8N-API-KEY` header, cursor pagination, 250 per page). If the list response lacks `nodes`, each changed workflow is fetched by id; unchanged ones (same `updatedAt` and `versionId`) are reused from the previous graph. `pinData` is dropped on load, string parameters are redacted (secret-looking keys and token shapes) before being stored for search, and execution data is not fetched (`--executions` records only the last status per workflow).
|
|
71
|
+
|
|
72
|
+
## Agents
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
flownav install claude --mcp # .claude/skills/flownav/SKILL.md + .mcp.json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The MCP server exposes `flownav_scan`, `explain`, `neighbors`, `path`, `find`, `affected`, `callers`, `calls`, `uses`, `check`, `report`, and reloads the graph file when it changes.
|
|
79
|
+
|
|
80
|
+
## Not done / assumptions to verify against your exports
|
|
81
|
+
|
|
82
|
+
- n8n field names are from the n8n export format as documented in `HANDOFF.md` and were exercised on the hand-written workflows in `examples/`, not on real exports. Check Execute Workflow `workflowId` shapes, `toolWorkflow`, `errorWorkflow` and sticky `width`/`height` on your own files.
|
|
83
|
+
- `GET /workflows` is assumed to return either full nodes or slim items; both paths are handled and tested against a mock server, not a real instance.
|
|
84
|
+
- OBSERVED confidence (edges seen in executions) is not implemented. Fragile reads use main-path ancestry, not dominators, so a read after a Merge of both branches is treated as safe.
|
|
85
|
+
- Name check: search npm and trademarks before publishing; run `npm publish --dry-run` early. Scope the name if taken (`@you/flownav`).
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm test # builds, then node --test on test/
|
|
91
|
+
```
|
package/dist/assemble.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { CROSS_KINDS } from './types.js';
|
|
2
|
+
import { shortType } from './util.js';
|
|
3
|
+
/** Bump whenever extraction logic changes so cached per-workflow results are not reused. */
|
|
4
|
+
export const BUILDER_VERSION = 4;
|
|
5
|
+
export const wfOf = (g, id) => {
|
|
6
|
+
if (id.startsWith('wf:'))
|
|
7
|
+
return id.slice(3);
|
|
8
|
+
const i = id.indexOf('::');
|
|
9
|
+
return i > 0 && g.workflows[id.slice(0, i)] ? id.slice(0, i) : undefined;
|
|
10
|
+
};
|
|
11
|
+
/** Recompute all cross-workflow edges (calls, on_error, webhook_calls) from node data. */
|
|
12
|
+
export function link(workflows, nodes) {
|
|
13
|
+
const edges = [];
|
|
14
|
+
const unresolved = [];
|
|
15
|
+
const byName = new Map();
|
|
16
|
+
for (const w of Object.values(workflows))
|
|
17
|
+
byName.set(w.name, [...(byName.get(w.name) ?? []), w.id]);
|
|
18
|
+
const entryOf = (wf, type) => nodes.find((n) => n.wf === wf && shortType(n.type) === type)?.id;
|
|
19
|
+
const resolve = (idOrName, name) => {
|
|
20
|
+
if (idOrName && workflows[idOrName])
|
|
21
|
+
return { id: idOrName, inferred: false };
|
|
22
|
+
for (const cand of [name, idOrName]) {
|
|
23
|
+
const hit = cand ? byName.get(cand) : undefined;
|
|
24
|
+
if (hit?.length === 1)
|
|
25
|
+
return { id: hit[0], inferred: true };
|
|
26
|
+
}
|
|
27
|
+
return { inferred: false };
|
|
28
|
+
};
|
|
29
|
+
for (const n of nodes) {
|
|
30
|
+
for (const c of n.calls ?? []) {
|
|
31
|
+
if (c.external) {
|
|
32
|
+
unresolved.push({ scope: 'cross', wf: n.wf, node: n.name, kind: 'external-subworkflow', detail: `${n.name} runs a sub-workflow from "${c.external}"` });
|
|
33
|
+
}
|
|
34
|
+
else if (c.dynamic) {
|
|
35
|
+
unresolved.push({ scope: 'cross', wf: n.wf, node: n.name, kind: 'dynamic-call', detail: `${n.name} calls a workflow chosen at runtime` });
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const r = resolve(c.targetId, c.targetName);
|
|
39
|
+
if (!r.id) {
|
|
40
|
+
unresolved.push({ scope: 'cross', wf: n.wf, node: n.name, kind: 'missing-workflow', detail: `${n.name} calls workflow ${c.targetId ?? c.targetName}, which is not in the graph` });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
edges.push({
|
|
44
|
+
from: n.id,
|
|
45
|
+
to: entryOf(r.id, 'executeWorkflowTrigger') ?? `wf:${r.id}`,
|
|
46
|
+
kind: 'calls',
|
|
47
|
+
label: c.via === 'toolWorkflow' ? 'tool' : undefined,
|
|
48
|
+
confidence: r.inferred ? 'INFERRED' : 'EXTRACTED',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const w of Object.values(workflows)) {
|
|
55
|
+
if (!w.errorWorkflow)
|
|
56
|
+
continue;
|
|
57
|
+
const r = resolve(w.errorWorkflow);
|
|
58
|
+
if (!r.id) {
|
|
59
|
+
unresolved.push({ scope: 'cross', wf: w.id, kind: 'missing-workflow', detail: `error workflow ${w.errorWorkflow} is not in the graph` });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
edges.push({ from: `wf:${w.id}`, to: entryOf(r.id, 'errorTrigger') ?? `wf:${r.id}`, kind: 'on_error', confidence: 'EXTRACTED' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// HTTP Request to /webhook/<path> matched against Webhook node paths.
|
|
66
|
+
const hooks = nodes
|
|
67
|
+
.filter((n) => n.webhook?.path)
|
|
68
|
+
.map((n) => ({
|
|
69
|
+
n,
|
|
70
|
+
re: new RegExp(`^${n.webhook.path.split('/').map((s) => (s.startsWith(':') ? '[^/]+' : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/')}$`),
|
|
71
|
+
}));
|
|
72
|
+
for (const n of nodes) {
|
|
73
|
+
for (const url of n.httpUrls ?? []) {
|
|
74
|
+
const m = /\/webhook(-test)?\/([^\s?#"'{}]+)/.exec(url);
|
|
75
|
+
if (!m)
|
|
76
|
+
continue;
|
|
77
|
+
const path = m[2].replace(/\/+$/, '');
|
|
78
|
+
for (const h of hooks) {
|
|
79
|
+
if (h.n.wf !== n.wf && h.re.test(path)) {
|
|
80
|
+
edges.push({ from: n.id, to: h.n.id, kind: 'webhook_calls', label: m[1] ? 'test' : undefined, confidence: 'INFERRED' });
|
|
81
|
+
}
|
|
82
|
+
else if (h.n.wf === n.wf && h.re.test(path)) {
|
|
83
|
+
edges.push({ from: n.id, to: h.n.id, kind: 'webhook_calls', label: 'self', confidence: 'INFERRED' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { edges, unresolved };
|
|
89
|
+
}
|
|
90
|
+
export function assemble(results, source) {
|
|
91
|
+
const workflows = {};
|
|
92
|
+
const nodes = [];
|
|
93
|
+
const edges = [];
|
|
94
|
+
const unresolved = [];
|
|
95
|
+
for (const r of results) {
|
|
96
|
+
workflows[r.info.id] = r.info;
|
|
97
|
+
nodes.push(...r.nodes);
|
|
98
|
+
edges.push(...r.edges.filter((e) => !CROSS_KINDS.includes(e.kind)));
|
|
99
|
+
unresolved.push(...r.unresolved.filter((u) => u.scope === 'local'));
|
|
100
|
+
}
|
|
101
|
+
const cross = link(workflows, nodes);
|
|
102
|
+
edges.push(...cross.edges);
|
|
103
|
+
unresolved.push(...cross.unresolved);
|
|
104
|
+
return { version: 1, builder: BUILDER_VERSION, builtAt: new Date().toISOString(), source, workflows, nodes, edges, unresolved };
|
|
105
|
+
}
|
|
106
|
+
/** Split a stored graph back into per-workflow results (cross edges dropped) for incremental sync. */
|
|
107
|
+
export function splitGraph(g) {
|
|
108
|
+
const owner = new Map();
|
|
109
|
+
for (const n of g.nodes)
|
|
110
|
+
owner.set(n.id, n.wf);
|
|
111
|
+
const res = new Map();
|
|
112
|
+
for (const info of Object.values(g.workflows))
|
|
113
|
+
res.set(info.id, { info, nodes: [], edges: [], unresolved: [] });
|
|
114
|
+
for (const n of g.nodes)
|
|
115
|
+
res.get(n.wf)?.nodes.push(n);
|
|
116
|
+
for (const e of g.edges) {
|
|
117
|
+
if (CROSS_KINDS.includes(e.kind))
|
|
118
|
+
continue;
|
|
119
|
+
const wf = owner.get(e.from) ?? owner.get(e.to);
|
|
120
|
+
if (wf)
|
|
121
|
+
res.get(wf)?.edges.push(e);
|
|
122
|
+
}
|
|
123
|
+
for (const u of g.unresolved)
|
|
124
|
+
if (u.scope === 'local')
|
|
125
|
+
res.get(u.wf)?.unresolved.push(u);
|
|
126
|
+
return res;
|
|
127
|
+
}
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { expressionBodies, extractRefs } from './expressions.js';
|
|
2
|
+
import { redactString, rlValue, sha, shortType, stable, trunc, walkStrings } from './util.js';
|
|
3
|
+
const CODE_KEYS = new Set(['jsCode', 'pythonCode', 'functionCode', 'code']);
|
|
4
|
+
const CODE_TYPES = new Set(['code', 'function', 'functionItem']);
|
|
5
|
+
const TRIGGER_NAMES = new Set(['webhook', 'manualTrigger', 'start', 'cron', 'interval']);
|
|
6
|
+
export function isTrigger(type) {
|
|
7
|
+
const t = shortType(type);
|
|
8
|
+
return TRIGGER_NAMES.has(t) || /trigger$/i.test(t);
|
|
9
|
+
}
|
|
10
|
+
/** Output labels in output-index order. Single-output nodes yield ['main']. */
|
|
11
|
+
export function outputLabels(n) {
|
|
12
|
+
const t = shortType(n.type);
|
|
13
|
+
const p = (n.parameters ?? {});
|
|
14
|
+
let out;
|
|
15
|
+
switch (t) {
|
|
16
|
+
case 'if':
|
|
17
|
+
out = ['true', 'false'];
|
|
18
|
+
break;
|
|
19
|
+
case 'splitInBatches':
|
|
20
|
+
out = ['done', 'loop'];
|
|
21
|
+
break;
|
|
22
|
+
case 'compareDatasets':
|
|
23
|
+
out = ['in A only', 'same', 'different', 'in B only'];
|
|
24
|
+
break;
|
|
25
|
+
case 'switch': {
|
|
26
|
+
const rules = p.rules?.values ?? p.rules?.rules ?? [];
|
|
27
|
+
if (p.mode === 'expression') {
|
|
28
|
+
const k = Number(p.numberOutputs ?? 4);
|
|
29
|
+
out = Array.from({ length: Number.isFinite(k) ? k : 4 }, (_, i) => `case ${i}`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
out = rules.map((r, i) => (typeof r?.outputKey === 'string' && r.outputKey ? r.outputKey : `case ${i}`));
|
|
33
|
+
if (!out.length)
|
|
34
|
+
out = ['case 0'];
|
|
35
|
+
}
|
|
36
|
+
if (p.options?.fallbackOutput === 'extra')
|
|
37
|
+
out.push('fallback');
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
default:
|
|
41
|
+
out = ['main'];
|
|
42
|
+
}
|
|
43
|
+
if (n.onError === 'continueErrorOutput')
|
|
44
|
+
out.push('error');
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function summarize(n) {
|
|
48
|
+
const t = shortType(n.type);
|
|
49
|
+
const p = (n.parameters ?? {});
|
|
50
|
+
const s = (v) => redactString(String(v ?? ''));
|
|
51
|
+
let out;
|
|
52
|
+
switch (t) {
|
|
53
|
+
case 'webhook':
|
|
54
|
+
out = `Webhook ${p.httpMethod ?? 'GET'} /${s(p.path)}${p.responseMode ? ` (${p.responseMode})` : ''}`;
|
|
55
|
+
break;
|
|
56
|
+
case 'httpRequest':
|
|
57
|
+
out = `HTTP ${p.method ?? 'GET'} ${s(p.url)}`;
|
|
58
|
+
break;
|
|
59
|
+
case 'set': {
|
|
60
|
+
const k = p.assignments?.assignments?.length ?? p.values?.string?.length ?? 0;
|
|
61
|
+
out = `Set ${k} field(s)`;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'code': {
|
|
65
|
+
const src = String(p.jsCode ?? p.pythonCode ?? '');
|
|
66
|
+
out = `Code (${p.language ?? 'js'}, ${src ? src.split('\n').length : 0} lines)`;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'if':
|
|
70
|
+
out = `IF ${p.conditions?.conditions?.length ?? ''} condition(s)`.replace(' ', ' ');
|
|
71
|
+
break;
|
|
72
|
+
case 'switch':
|
|
73
|
+
out = `Switch (${outputLabels(n).length} outputs)`;
|
|
74
|
+
break;
|
|
75
|
+
case 'executeWorkflow':
|
|
76
|
+
case 'toolWorkflow':
|
|
77
|
+
out = `${t === 'toolWorkflow' ? 'Tool: ' : ''}Execute workflow ${rlValue(p.workflowId).value ?? rlValue(p.workflowId).name ?? '?'}`;
|
|
78
|
+
break;
|
|
79
|
+
case 'agent':
|
|
80
|
+
out = 'AI Agent';
|
|
81
|
+
break;
|
|
82
|
+
case 'scheduleTrigger':
|
|
83
|
+
out = 'Schedule trigger';
|
|
84
|
+
break;
|
|
85
|
+
default: {
|
|
86
|
+
const bits = [p.resource, p.operation].filter((x) => typeof x === 'string');
|
|
87
|
+
out = bits.length ? `${t}: ${bits.join(' ')}` : t;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return trunc(out, 110);
|
|
91
|
+
}
|
|
92
|
+
function callRefs(n) {
|
|
93
|
+
const t = shortType(n.type);
|
|
94
|
+
if (t !== 'executeWorkflow' && t !== 'toolWorkflow')
|
|
95
|
+
return [];
|
|
96
|
+
const p = (n.parameters ?? {});
|
|
97
|
+
const source = String(p.source ?? 'database');
|
|
98
|
+
if (source === 'parameter')
|
|
99
|
+
return [{ via: t, external: 'embedded' }];
|
|
100
|
+
if (source === 'url' || source === 'localFile')
|
|
101
|
+
return [{ via: t, external: source }];
|
|
102
|
+
const { value, name } = rlValue(p.workflowId);
|
|
103
|
+
if (!value && !name)
|
|
104
|
+
return [{ via: t, dynamic: true }];
|
|
105
|
+
if (value?.startsWith('='))
|
|
106
|
+
return [{ via: t, dynamic: true }];
|
|
107
|
+
return [{ via: t, targetId: value, targetName: name }];
|
|
108
|
+
}
|
|
109
|
+
const READ_OPS = new Set(['get', 'getAll', 'getMany', 'read', 'search', 'select', 'list', 'lookup', 'find']);
|
|
110
|
+
const WRITE_OPS = new Set([
|
|
111
|
+
'append', 'appendOrUpdate', 'update', 'upsert', 'insert', 'delete', 'create', 'send', 'post', 'remove', 'clear', 'upload', 'replace',
|
|
112
|
+
]);
|
|
113
|
+
const RES_KEYS = {
|
|
114
|
+
documentId: 'doc', spreadsheetId: 'doc', sheetName: 'sheet', table: 'table', tableId: 'table', schema: 'schema',
|
|
115
|
+
channelId: 'channel', channel: 'channel', baseId: 'base', base: 'base', fileId: 'file', folderId: 'folder', databaseId: 'database',
|
|
116
|
+
};
|
|
117
|
+
function resources(n) {
|
|
118
|
+
const p = (n.parameters ?? {});
|
|
119
|
+
const t = shortType(n.type).replace(/Trigger$/, '');
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [k, kind] of Object.entries(RES_KEYS)) {
|
|
122
|
+
const { value } = rlValue(p[k]);
|
|
123
|
+
if (value && !value.startsWith('='))
|
|
124
|
+
out.push(`res:${t}:${kind}=${value}`);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function stripStickyTitle(content, x, y) {
|
|
129
|
+
const line = content.split('\n').map((l) => l.replace(/^[#*\s_>-]+|[*_\s]+$/g, '')).find(Boolean);
|
|
130
|
+
return trunc(line || `group@${x},${y}`, 60);
|
|
131
|
+
}
|
|
132
|
+
export function buildWorkflow(raw, id, file) {
|
|
133
|
+
const all = (raw.nodes ?? []).filter((n) => n && typeof n.name === 'string' && typeof n.type === 'string');
|
|
134
|
+
const stickies = all.filter((n) => shortType(n.type) === 'stickyNote');
|
|
135
|
+
const stickyNames = new Set(stickies.map((n) => n.name));
|
|
136
|
+
const real = all.filter((n) => shortType(n.type) !== 'stickyNote');
|
|
137
|
+
const byName = new Map(real.map((n) => [n.name, n]));
|
|
138
|
+
const nid = (name) => `${id}::${name}`;
|
|
139
|
+
// Groups from sticky-note rectangles (smallest containing sticky wins).
|
|
140
|
+
const rects = stickies
|
|
141
|
+
.filter((s) => Array.isArray(s.position))
|
|
142
|
+
.map((s) => {
|
|
143
|
+
const p = (s.parameters ?? {});
|
|
144
|
+
const [x, y] = s.position;
|
|
145
|
+
const w = Number(p.width ?? 240);
|
|
146
|
+
const h = Number(p.height ?? 160);
|
|
147
|
+
return { x, y, w, h, name: stripStickyTitle(String(p.content ?? ''), x, y) };
|
|
148
|
+
});
|
|
149
|
+
const groups = new Map();
|
|
150
|
+
const groupOf = (n) => {
|
|
151
|
+
if (!Array.isArray(n.position))
|
|
152
|
+
return undefined;
|
|
153
|
+
const cx = n.position[0] + 50;
|
|
154
|
+
const cy = n.position[1] + 50;
|
|
155
|
+
const hit = rects
|
|
156
|
+
.filter((r) => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h)
|
|
157
|
+
.sort((a, b) => a.w * a.h - b.w * b.h)[0];
|
|
158
|
+
return hit?.name;
|
|
159
|
+
};
|
|
160
|
+
const edges = [];
|
|
161
|
+
const unresolved = [];
|
|
162
|
+
const nodes = [];
|
|
163
|
+
for (const n of real) {
|
|
164
|
+
const outputs = outputLabels(n);
|
|
165
|
+
const credentials = Object.entries(n.credentials ?? {}).map(([type, c]) => ({
|
|
166
|
+
type, id: String(c?.id ?? c?.name ?? type), name: c?.name,
|
|
167
|
+
}));
|
|
168
|
+
const texts = [];
|
|
169
|
+
walkStrings(n.parameters, (s) => texts.push(s));
|
|
170
|
+
const p = (n.parameters ?? {});
|
|
171
|
+
const t = shortType(n.type);
|
|
172
|
+
const urls = t === 'httpRequest' && typeof p.url === 'string' ? [redactString(p.url)] : undefined;
|
|
173
|
+
const group = groupOf(n);
|
|
174
|
+
if (group)
|
|
175
|
+
groups.set(group, [...(groups.get(group) ?? []), n.name]);
|
|
176
|
+
const calls = callRefs(n);
|
|
177
|
+
const node = {
|
|
178
|
+
id: nid(n.name),
|
|
179
|
+
wf: id,
|
|
180
|
+
name: n.name,
|
|
181
|
+
type: n.type,
|
|
182
|
+
typeVersion: n.typeVersion,
|
|
183
|
+
summary: summarize(n),
|
|
184
|
+
disabled: n.disabled === true,
|
|
185
|
+
trigger: isTrigger(n.type),
|
|
186
|
+
outputs,
|
|
187
|
+
credentials,
|
|
188
|
+
group,
|
|
189
|
+
hash: sha(stable({ t: n.type, v: n.typeVersion, p: n.parameters, c: credentials.map((c) => c.id), d: n.disabled, e: n.onError })),
|
|
190
|
+
text: trunc(texts.join(' | '), 3000),
|
|
191
|
+
onError: n.onError,
|
|
192
|
+
httpUrls: urls,
|
|
193
|
+
calls: calls.length ? calls : undefined,
|
|
194
|
+
};
|
|
195
|
+
if (t === 'webhook') {
|
|
196
|
+
node.webhook = {
|
|
197
|
+
path: String(p.path ?? '').replace(/^\/+|\/+$/g, ''),
|
|
198
|
+
method: p.httpMethod ? String(p.httpMethod) : undefined,
|
|
199
|
+
responseMode: p.responseMode ? String(p.responseMode) : undefined,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
nodes.push(node);
|
|
203
|
+
for (const c of credentials) {
|
|
204
|
+
edges.push({ from: node.id, to: `cred:${c.id}`, kind: 'uses', label: c.type, confidence: 'EXPLICIT' });
|
|
205
|
+
}
|
|
206
|
+
const op = typeof p.operation === 'string' ? p.operation : '';
|
|
207
|
+
for (const r of resources(n)) {
|
|
208
|
+
if (READ_OPS.has(op))
|
|
209
|
+
edges.push({ from: r, to: node.id, kind: 'reads_resource', label: op, confidence: 'INFERRED' });
|
|
210
|
+
else if (WRITE_OPS.has(op))
|
|
211
|
+
edges.push({ from: node.id, to: r, kind: 'writes_resource', label: op, confidence: 'INFERRED' });
|
|
212
|
+
else
|
|
213
|
+
edges.push({ from: node.id, to: r, kind: 'uses_resource', label: op || undefined, confidence: 'INFERRED' });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Connections -> main / capability edges.
|
|
217
|
+
const labelFor = (src, oi) => {
|
|
218
|
+
const labels = outputLabels(src);
|
|
219
|
+
if (labels.length > 1)
|
|
220
|
+
return labels[oi] ?? `output ${oi}`;
|
|
221
|
+
return oi > 0 ? `output ${oi}` : undefined;
|
|
222
|
+
};
|
|
223
|
+
for (const [srcName, byType] of Object.entries(raw.connections ?? {})) {
|
|
224
|
+
if (stickyNames.has(srcName))
|
|
225
|
+
continue;
|
|
226
|
+
for (const [ctype, outs] of Object.entries(byType ?? {})) {
|
|
227
|
+
(outs ?? []).forEach((targets, oi) => {
|
|
228
|
+
for (const t of targets ?? []) {
|
|
229
|
+
if (!t?.node || stickyNames.has(t.node))
|
|
230
|
+
continue;
|
|
231
|
+
const src = byName.get(srcName);
|
|
232
|
+
if (!src || !byName.has(t.node)) {
|
|
233
|
+
unresolved.push({ scope: 'local', wf: id, node: srcName, kind: 'dead-connection', detail: `${srcName} -> ${t.node}: node does not exist` });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
edges.push({
|
|
237
|
+
from: nid(srcName),
|
|
238
|
+
to: nid(t.node),
|
|
239
|
+
kind: ctype === 'main' ? 'main' : 'capability',
|
|
240
|
+
label: ctype === 'main' ? labelFor(src, oi) : ctype,
|
|
241
|
+
confidence: 'EXPLICIT',
|
|
242
|
+
outputIndex: oi,
|
|
243
|
+
inputIndex: t.index ?? 0,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Expression / code references -> reads edges (source -> reader).
|
|
250
|
+
for (const n of real) {
|
|
251
|
+
const isCodeNode = CODE_TYPES.has(shortType(n.type));
|
|
252
|
+
const acc = new Map();
|
|
253
|
+
const inf = new Map();
|
|
254
|
+
const envs = new Set();
|
|
255
|
+
let dynamic = 0;
|
|
256
|
+
walkStrings(n.parameters, (s, path, key) => {
|
|
257
|
+
let body;
|
|
258
|
+
// Only {{ }} blocks are expressions; the rest of an "=" string is literal text
|
|
259
|
+
// (e.g. an HTML page whose browser JS contains $('form')).
|
|
260
|
+
if (s.startsWith('='))
|
|
261
|
+
body = expressionBodies(s);
|
|
262
|
+
else if (isCodeNode && CODE_KEYS.has(key))
|
|
263
|
+
body = s;
|
|
264
|
+
if (!body)
|
|
265
|
+
return;
|
|
266
|
+
const ex = extractRefs(body);
|
|
267
|
+
dynamic += ex.dynamic;
|
|
268
|
+
ex.env.forEach((e) => envs.add(`res:env:${e}`));
|
|
269
|
+
ex.vars.forEach((e) => envs.add(`res:var:${e}`));
|
|
270
|
+
for (const name of ex.inferred)
|
|
271
|
+
inf.set(name, new Set([...(inf.get(name) ?? []), path]));
|
|
272
|
+
for (const r of ex.refs) {
|
|
273
|
+
const a = acc.get(r.name) ?? { fields: new Set(), params: new Set() };
|
|
274
|
+
r.fields.forEach((f) => a.fields.add(f));
|
|
275
|
+
a.params.add(path);
|
|
276
|
+
acc.set(r.name, a);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
for (const [name, a] of acc) {
|
|
280
|
+
if (name === n.name || stickyNames.has(name))
|
|
281
|
+
continue;
|
|
282
|
+
if (!byName.has(name)) {
|
|
283
|
+
unresolved.push({ scope: 'local', wf: id, node: n.name, kind: 'dead-ref', detail: `${n.name} reads "${name}", which does not exist` });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
edges.push({
|
|
287
|
+
from: nid(name), to: nid(n.name), kind: 'reads', confidence: 'EXTRACTED',
|
|
288
|
+
fields: [...a.fields].sort(), params: [...a.params].sort(),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
// Names reached via a loop variable over a literal list: real dependencies, but only INFERRED.
|
|
292
|
+
for (const [name, params] of inf) {
|
|
293
|
+
if (acc.has(name) || name === n.name || stickyNames.has(name))
|
|
294
|
+
continue;
|
|
295
|
+
if (!byName.has(name)) {
|
|
296
|
+
unresolved.push({ scope: 'local', wf: id, node: n.name, kind: 'maybe-dead-ref', detail: `${n.name} may read "${name}" (from a literal list), which does not exist` });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
edges.push({ from: nid(name), to: nid(n.name), kind: 'reads', confidence: 'INFERRED', fields: [], params: [...params].sort() });
|
|
300
|
+
}
|
|
301
|
+
for (const e of envs)
|
|
302
|
+
edges.push({ from: e, to: nid(n.name), kind: 'reads_resource', label: 'env', confidence: 'EXTRACTED' });
|
|
303
|
+
if (dynamic) {
|
|
304
|
+
unresolved.push({ scope: 'local', wf: id, node: n.name, kind: 'dynamic-ref', detail: `${n.name} has ${dynamic} node reference(s) with a computed name` });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Webhook -> Respond to Webhook (reachability over main).
|
|
308
|
+
const out = new Map();
|
|
309
|
+
for (const e of edges)
|
|
310
|
+
if (e.kind === 'main')
|
|
311
|
+
out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
|
|
312
|
+
for (const w of nodes.filter((x) => x.webhook)) {
|
|
313
|
+
const seen = new Set([w.id]);
|
|
314
|
+
const queue = [w.id];
|
|
315
|
+
while (queue.length) {
|
|
316
|
+
const cur = queue.shift();
|
|
317
|
+
for (const nx of out.get(cur) ?? [])
|
|
318
|
+
if (!seen.has(nx)) {
|
|
319
|
+
seen.add(nx);
|
|
320
|
+
queue.push(nx);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const nd of nodes) {
|
|
324
|
+
if (seen.has(nd.id) && shortType(nd.type) === 'respondToWebhook') {
|
|
325
|
+
edges.push({ from: w.id, to: nd.id, kind: 'responds_via', confidence: 'EXTRACTED' });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const errWf = raw.settings?.errorWorkflow;
|
|
330
|
+
const info = {
|
|
331
|
+
id,
|
|
332
|
+
name: raw.name || id,
|
|
333
|
+
active: typeof raw.active === 'boolean' ? raw.active : undefined,
|
|
334
|
+
updatedAt: raw.updatedAt,
|
|
335
|
+
versionId: raw.versionId,
|
|
336
|
+
tags: (raw.tags ?? []).map((t) => (typeof t === 'string' ? t : String(t?.name ?? ''))).filter(Boolean),
|
|
337
|
+
file,
|
|
338
|
+
errorWorkflow: errWf != null && String(errWf) && String(errWf) !== 'DEFAULT' ? String(errWf) : undefined,
|
|
339
|
+
nodeCount: real.length,
|
|
340
|
+
hash: sha(stable({ n: nodes.map((x) => x.hash).sort(), c: raw.connections ?? {} })),
|
|
341
|
+
groups: [...groups].map(([name, ns]) => ({ name, nodes: ns })),
|
|
342
|
+
};
|
|
343
|
+
return { info, nodes, edges, unresolved };
|
|
344
|
+
}
|