@cargo-ai/cli 1.0.53 → 1.0.54
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/build/commands/orchestration/node.d.ts.map +1 -1
- package/build/commands/orchestration/node.js +145 -6
- package/build/commands/orchestration/nodeDiagram.d.ts +45 -0
- package/build/commands/orchestration/nodeDiagram.d.ts.map +1 -0
- package/build/commands/orchestration/nodeDiagram.js +373 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../../src/commands/orchestration/node.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../../src/commands/orchestration/node.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAexC,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,GAAG,IAAI,CAoK7E"}
|
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { ExitCodes, failWith, handleApiCall, outputJson, parseJson, } from "../runHandler.js";
|
|
3
|
+
import { extractNodes, renderNodeDiagram, toMermaidBlock, } from "./nodeDiagram.js";
|
|
2
4
|
export function registerNodeCommands(parent, getApi) {
|
|
3
5
|
const node = parent
|
|
4
6
|
.command("node")
|
|
5
|
-
.description("
|
|
7
|
+
.description("Work with a workflow's node graph: draw it, validate it, compute a node's config, execute a single node")
|
|
6
8
|
.addHelpText("after", `
|
|
7
|
-
Use these commands to debug
|
|
9
|
+
Use these commands to inspect or debug the node graph of a workflow. To just
|
|
8
10
|
run a tool or connector action, no workflow needed, use instead:
|
|
9
11
|
$ cargo-ai orchestration action execute --action '<json>' --data '<json>'
|
|
10
12
|
|
|
11
|
-
'compute' and 'validate' are free and run nothing. 'execute' is not
|
|
12
|
-
it performs the node's real action and consumes credits.
|
|
13
|
+
'diagram', 'compute' and 'validate' are free and run nothing. 'execute' is not
|
|
14
|
+
a dry run: it performs the node's real action and consumes credits.
|
|
15
|
+
|
|
16
|
+
Before deploying a graph:
|
|
17
|
+
$ cargo-ai orchestration node validate --nodes '<json>'
|
|
18
|
+
$ cargo-ai orchestration node diagram --nodes '<json>' --raw
|
|
13
19
|
|
|
14
20
|
Debugging loop:
|
|
15
21
|
$ cargo-ai orchestration span list --run-uuid <uuid> --execution-started-after <iso-date>
|
|
16
|
-
$ cargo-ai orchestration
|
|
22
|
+
$ cargo-ai orchestration node diagram --run-uuid <uuid> --highlight <slug> --raw
|
|
17
23
|
$ cargo-ai orchestration node compute --node '<json>' --context '<json>'
|
|
18
24
|
$ cargo-ai orchestration node execute --workflow-uuid <uuid> --release-uuid <uuid> --node '<json>' --computed-config '<json>' --context '<json>'`);
|
|
25
|
+
registerDiagramCommand(node, getApi);
|
|
19
26
|
node
|
|
20
27
|
.command("compute")
|
|
21
28
|
.description("Compute a node's configuration from its definition and context")
|
|
@@ -95,3 +102,135 @@ Examples:
|
|
|
95
102
|
outputJson(result);
|
|
96
103
|
});
|
|
97
104
|
}
|
|
105
|
+
function registerDiagramCommand(node, getApi) {
|
|
106
|
+
node
|
|
107
|
+
.command("diagram")
|
|
108
|
+
.description("Draw a node graph as a Mermaid flowchart (free, runs nothing, no credits)")
|
|
109
|
+
.option("--nodes <json>", "Node definitions (JSON array, or '-' to read from stdin)")
|
|
110
|
+
.option("--file <path>", "Read a JSON payload or node array from a file")
|
|
111
|
+
.option("--workflow-uuid <uuid>", "Diagram a workflow's deployed release (add --draft for its draft)")
|
|
112
|
+
.option("--draft", "With --workflow-uuid, use the draft release")
|
|
113
|
+
.option("--release-uuid <uuid>", "Diagram a specific release")
|
|
114
|
+
.option("--run-uuid <uuid>", "Diagram the graph a run executed (follows the run's release when it has one)")
|
|
115
|
+
.option("--title <text>", "Title rendered above the diagram")
|
|
116
|
+
.option("--direction <TD|LR>", "Flow direction (default: TD)", "TD")
|
|
117
|
+
.option("--paid <slugs>", "Comma-separated node slugs/uuids that bill credits — marked 💳")
|
|
118
|
+
.option("--highlight <slugs>", "Comma-separated node slugs/uuids to mark red, e.g. a failing node")
|
|
119
|
+
.option("--raw", "Print the fenced Mermaid block instead of JSON")
|
|
120
|
+
.addHelpText("after", `
|
|
121
|
+
Free, runs nothing: turns a node graph into a picture so a reviewer can see the
|
|
122
|
+
routing, the fallback paths and which steps bill before approving or deploying
|
|
123
|
+
it. Pass exactly one source.
|
|
124
|
+
|
|
125
|
+
Returns {"diagram":"flowchart TD…","format":"mermaid","warnings":[…]}, or the
|
|
126
|
+
fenced block itself with --raw. 'warnings' reports structural problems worth
|
|
127
|
+
saying out loud — nodes unreachable from start, dangling childrenUuids.
|
|
128
|
+
|
|
129
|
+
A run created by 'action execute' carries its own graph; a run of a deployed
|
|
130
|
+
tool or play carries only a releaseUuid, and --run-uuid follows it either way.
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
$ cargo-ai orchestration node diagram --workflow-uuid <uuid> --raw
|
|
134
|
+
$ cargo-ai orchestration node diagram --workflow-uuid <uuid> --draft --paid enrich,verify
|
|
135
|
+
$ cargo-ai orchestration node diagram --run-uuid <uuid> --highlight branch_1 --raw
|
|
136
|
+
$ cargo-ai orchestration release get-deployed --workflow-uuid <uuid> | cargo-ai orchestration node diagram --nodes -`)
|
|
137
|
+
.action(async (opts) => {
|
|
138
|
+
const nodes = await resolveNodes(getApi, opts);
|
|
139
|
+
const result = renderNodeDiagram(nodes, {
|
|
140
|
+
title: opts.title,
|
|
141
|
+
direction: (opts.direction ?? "TD").toUpperCase(),
|
|
142
|
+
paid: splitList(opts.paid),
|
|
143
|
+
highlight: splitList(opts.highlight),
|
|
144
|
+
});
|
|
145
|
+
if (opts.raw === true) {
|
|
146
|
+
console.log(toMermaidBlock(result.diagram));
|
|
147
|
+
for (const warning of result.warnings)
|
|
148
|
+
console.error(`âš ${warning}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
outputJson(result);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function splitList(value) {
|
|
155
|
+
if (value === undefined)
|
|
156
|
+
return [];
|
|
157
|
+
return value
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((entry) => entry.trim())
|
|
160
|
+
.filter((entry) => entry.length > 0);
|
|
161
|
+
}
|
|
162
|
+
async function resolveNodes(getApi, opts) {
|
|
163
|
+
const sources = [
|
|
164
|
+
opts.nodes !== undefined ? "--nodes" : undefined,
|
|
165
|
+
opts.file !== undefined ? "--file" : undefined,
|
|
166
|
+
opts.workflowUuid !== undefined ? "--workflow-uuid" : undefined,
|
|
167
|
+
opts.releaseUuid !== undefined ? "--release-uuid" : undefined,
|
|
168
|
+
opts.runUuid !== undefined ? "--run-uuid" : undefined,
|
|
169
|
+
].filter((source) => source !== undefined);
|
|
170
|
+
if (sources.length === 0) {
|
|
171
|
+
failWith("pass one of --nodes, --file, --workflow-uuid, --release-uuid or --run-uuid", { code: ExitCodes.InvalidUsage });
|
|
172
|
+
}
|
|
173
|
+
if (sources.length > 1) {
|
|
174
|
+
failWith(`${sources.join(" and ")} are mutually exclusive`, {
|
|
175
|
+
code: ExitCodes.InvalidUsage,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (opts.nodes !== undefined) {
|
|
179
|
+
const raw = opts.nodes === "-" ? await readStdin() : opts.nodes;
|
|
180
|
+
return fromPayload(parseJson(raw, "--nodes"), "--nodes");
|
|
181
|
+
}
|
|
182
|
+
if (opts.file !== undefined) {
|
|
183
|
+
return fromPayload(parseJson(readFileSync(opts.file, "utf8"), "--file"), "--file");
|
|
184
|
+
}
|
|
185
|
+
const api = getApi();
|
|
186
|
+
if (opts.workflowUuid !== undefined) {
|
|
187
|
+
const workflowUuid = opts.workflowUuid;
|
|
188
|
+
const result = await handleApiCall(() => opts.draft === true
|
|
189
|
+
? api.orchestration.draftRelease.get({ workflowUuid })
|
|
190
|
+
: api.orchestration.release.getDeployed({ workflowUuid }));
|
|
191
|
+
return fromPayload(result, "--workflow-uuid");
|
|
192
|
+
}
|
|
193
|
+
if (opts.releaseUuid !== undefined) {
|
|
194
|
+
const releaseUuid = opts.releaseUuid;
|
|
195
|
+
const result = await handleApiCall(() => api.orchestration.release.get(releaseUuid));
|
|
196
|
+
return fromPayload(result, "--release-uuid");
|
|
197
|
+
}
|
|
198
|
+
// A run either carries its own graph (`action execute`, `run create --nodes`)
|
|
199
|
+
// or points at the release that holds one — never both. Follow whichever it
|
|
200
|
+
// has, so the caller does not have to know which kind of run they were given.
|
|
201
|
+
const runUuid = opts.runUuid;
|
|
202
|
+
if (runUuid === undefined) {
|
|
203
|
+
failWith("no graph source resolved", { code: ExitCodes.InvalidUsage });
|
|
204
|
+
}
|
|
205
|
+
const run = await handleApiCall(() => api.orchestration.run.get(runUuid));
|
|
206
|
+
const inline = extractNodes(run);
|
|
207
|
+
if (inline !== undefined)
|
|
208
|
+
return inline;
|
|
209
|
+
const releaseUuid = run.run
|
|
210
|
+
?.releaseUuid;
|
|
211
|
+
if (releaseUuid === null || releaseUuid === undefined) {
|
|
212
|
+
failWith(`run ${runUuid} carries neither a node graph nor a release`, {
|
|
213
|
+
code: ExitCodes.NotFound,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const release = await handleApiCall(() => api.orchestration.release.get(releaseUuid));
|
|
217
|
+
return fromPayload(release, "--run-uuid");
|
|
218
|
+
}
|
|
219
|
+
function fromPayload(payload, source) {
|
|
220
|
+
const nodes = extractNodes(payload);
|
|
221
|
+
if (nodes === undefined) {
|
|
222
|
+
failWith(`no node graph found in the payload passed to ${source} — expected a node array, or the output of 'release get', 'release get-draft', 'template get' or 'run get'`, { code: ExitCodes.InvalidUsage });
|
|
223
|
+
}
|
|
224
|
+
return nodes;
|
|
225
|
+
}
|
|
226
|
+
async function readStdin() {
|
|
227
|
+
if (process.stdin.isTTY === true) {
|
|
228
|
+
failWith("--nodes - expects JSON on stdin", {
|
|
229
|
+
code: ExitCodes.InvalidUsage,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const chunks = [];
|
|
233
|
+
for await (const chunk of process.stdin)
|
|
234
|
+
chunks.push(chunk);
|
|
235
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
236
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Structural shape of a node — deliberately loose, so this module stays
|
|
2
|
+
* decoupled from the generated API types and can also render a hand-written
|
|
3
|
+
* graph that has not been deployed yet. */
|
|
4
|
+
export type DiagramNode = {
|
|
5
|
+
uuid: string;
|
|
6
|
+
slug?: string | null;
|
|
7
|
+
name?: string | null;
|
|
8
|
+
kind?: string | null;
|
|
9
|
+
actionSlug?: string | null;
|
|
10
|
+
integrationSlug?: string | null;
|
|
11
|
+
toolUuid?: string | null;
|
|
12
|
+
agentUuid?: string | null;
|
|
13
|
+
/** Template slug for tool/agent nodes sourced from a template graph. */
|
|
14
|
+
templateSlug?: string | null;
|
|
15
|
+
childrenUuids?: (string | null)[] | null;
|
|
16
|
+
fallbackChildUuid?: string | null;
|
|
17
|
+
config?: Record<string, unknown> | null;
|
|
18
|
+
};
|
|
19
|
+
export type DiagramOptions = {
|
|
20
|
+
title?: string;
|
|
21
|
+
direction?: string;
|
|
22
|
+
/** Node slugs or uuids that bill credits — rendered with a 💳. */
|
|
23
|
+
paid?: string[];
|
|
24
|
+
/** Node slugs or uuids to mark red, e.g. the failing node in a trace. */
|
|
25
|
+
highlight?: string[];
|
|
26
|
+
};
|
|
27
|
+
export type DiagramResult = {
|
|
28
|
+
diagram: string;
|
|
29
|
+
format: "mermaid";
|
|
30
|
+
/** Structural problems worth saying out loud rather than drawing over. */
|
|
31
|
+
warnings: string[];
|
|
32
|
+
};
|
|
33
|
+
/** What the node is, in words a user recognises. */
|
|
34
|
+
export declare function labelFor(node: DiagramNode): string;
|
|
35
|
+
export declare function renderNodeDiagram(nodes: DiagramNode[], options?: DiagramOptions): DiagramResult;
|
|
36
|
+
/** Node-shaped means: a uuid plus the fields a graph is walked by. */
|
|
37
|
+
export declare function isNodeArray(value: unknown): value is DiagramNode[];
|
|
38
|
+
/**
|
|
39
|
+
* Pull the graph out of a CLI payload: `release get`, `release get-draft`,
|
|
40
|
+
* `template get`, an ad-hoc `run get`, or a bare node array.
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractNodes(payload: unknown): DiagramNode[] | undefined;
|
|
43
|
+
/** The fenced block, ready to paste into a message, a PR, or the docs. */
|
|
44
|
+
export declare function toMermaidBlock(diagram: string): string;
|
|
45
|
+
//# sourceMappingURL=nodeDiagram.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nodeDiagram.d.ts","sourceRoot":"","sources":["../../../src/commands/orchestration/nodeDiagram.ts"],"names":[],"mappings":"AAgBA;;2CAE2C;AAC3C,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,aAAa,CAAC,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC;IACzC,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,CAAC;IAClB,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAsHF,oDAAoD;AACpD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,CASlD;AAyND,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAAE,EACpB,OAAO,GAAE,cAAmB,GAC3B,aAAa,CAkCf;AAMD,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,EAAE,CAYlE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,WAAW,EAAE,GAAG,SAAS,CAexE;AAED,0EAA0E;AAC1E,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEtD"}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Render a workflow node graph as a Mermaid flowchart.
|
|
2
|
+
//
|
|
3
|
+
// A node graph carries routing, fallback paths and paid steps; prose flattens
|
|
4
|
+
// all three, so "what does this play do?" is best answered with a picture. Every
|
|
5
|
+
// surface the CLI prints into — coding agents, GitHub, the docs — renders
|
|
6
|
+
// Mermaid, and this is a pure local transform: no API call, no credits.
|
|
7
|
+
//
|
|
8
|
+
// Two properties of real releases drive the implementation:
|
|
9
|
+
//
|
|
10
|
+
// * `slug` is NOT unique within a release. A shipped waterfall carries six
|
|
11
|
+
// nodes slugged `variables`. Everything here keys on `uuid`; slugs are used
|
|
12
|
+
// for labels and for matching user-supplied --paid / --highlight only.
|
|
13
|
+
// * `childrenUuids` order carries the routing semantics (index 0 of a
|
|
14
|
+
// `branch` is the matched path), so edge labels are table-driven rather
|
|
15
|
+
// than inferred from node names.
|
|
16
|
+
const ROUTING_ACTIONS = [
|
|
17
|
+
"branch",
|
|
18
|
+
"filter",
|
|
19
|
+
"switch",
|
|
20
|
+
"split",
|
|
21
|
+
"balance",
|
|
22
|
+
"humanReview",
|
|
23
|
+
];
|
|
24
|
+
const CODE_ACTIONS = ["python", "script"];
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Shapes and labels
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
function shapeFor(node) {
|
|
29
|
+
const action = node.actionSlug ?? "";
|
|
30
|
+
if (node.kind === "native") {
|
|
31
|
+
if (action === "start" || action === "end")
|
|
32
|
+
return ["([", "])"];
|
|
33
|
+
if (ROUTING_ACTIONS.includes(action))
|
|
34
|
+
return ["{", "}"];
|
|
35
|
+
if (CODE_ACTIONS.includes(action))
|
|
36
|
+
return ["[/", "/]"];
|
|
37
|
+
if (action === "agent")
|
|
38
|
+
return ["{{", "}}"];
|
|
39
|
+
if (action === "group")
|
|
40
|
+
return ["[", "]"];
|
|
41
|
+
return ["(", ")"];
|
|
42
|
+
}
|
|
43
|
+
if (node.kind === "tool")
|
|
44
|
+
return ["[[", "]]"];
|
|
45
|
+
if (node.kind === "agent")
|
|
46
|
+
return ["{{", "}}"];
|
|
47
|
+
return ["[", "]"]; // connector, and anything unrecognised
|
|
48
|
+
}
|
|
49
|
+
// Labels are emitted quoted, so brackets and parens inside them are safe; a raw
|
|
50
|
+
// quote, angle bracket, pipe or newline is not — those end the label early or
|
|
51
|
+
// inject markup into Mermaid's HTML labels. Pipe also breaks edge labels
|
|
52
|
+
// (|…|), so it must be escaped everywhere.
|
|
53
|
+
function escapeLabel(text) {
|
|
54
|
+
return text
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.replace(/"/g, "#quot;")
|
|
57
|
+
.replace(/</g, "#lt;")
|
|
58
|
+
.replace(/>/g, "#gt;")
|
|
59
|
+
.replace(/\|/g, "#124;")
|
|
60
|
+
.trim();
|
|
61
|
+
}
|
|
62
|
+
function truncate(text, max = 60) {
|
|
63
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
64
|
+
}
|
|
65
|
+
function configString(node, key) {
|
|
66
|
+
const value = node.config?.[key];
|
|
67
|
+
return typeof value === "string" ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
// A constant config number, however the graph happens to spell it: `30`, the
|
|
70
|
+
// string `"30"`, or a `{{ 30 }}` template body. Anchored, so a real expression
|
|
71
|
+
// (`{{ nodes.start.pct }}`) has no match and stays unlabelled rather than
|
|
72
|
+
// showing a number the run will not use.
|
|
73
|
+
const CONSTANT_NUMBER = /^\s*(?:\{\{\s*([+-]?\d+(?:\.\d+)?)\s*\}\}|([+-]?\d+(?:\.\d+)?))\s*$/;
|
|
74
|
+
/**
|
|
75
|
+
* A numeric config value. Only the canvas writes these as raw JSON numbers: a
|
|
76
|
+
* stored release and an SDK-compiled graph both wrap them in the wire's
|
|
77
|
+
* expression object — `{ kind: "templateExpression", expression: "{{ 30 }}" }`
|
|
78
|
+
* — which the engine resolves before the action sees it. Reading only raw
|
|
79
|
+
* numbers loses the value on every graph that was not hand-written.
|
|
80
|
+
*/
|
|
81
|
+
function configNumber(node, key) {
|
|
82
|
+
const value = node.config?.[key];
|
|
83
|
+
if (typeof value === "number") {
|
|
84
|
+
return Number.isFinite(value) ? value : undefined;
|
|
85
|
+
}
|
|
86
|
+
let source;
|
|
87
|
+
if (typeof value === "string") {
|
|
88
|
+
source = value;
|
|
89
|
+
}
|
|
90
|
+
else if (value !== null && typeof value === "object") {
|
|
91
|
+
const expression = value.expression;
|
|
92
|
+
if (typeof expression === "string")
|
|
93
|
+
source = expression;
|
|
94
|
+
}
|
|
95
|
+
if (source === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const match = CONSTANT_NUMBER.exec(source);
|
|
98
|
+
if (match === null)
|
|
99
|
+
return undefined;
|
|
100
|
+
const digits = match[1] !== undefined ? match[1] : match[2];
|
|
101
|
+
if (digits === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
const parsed = Number(digits);
|
|
104
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
105
|
+
}
|
|
106
|
+
/** The second label line: what the node actually calls. */
|
|
107
|
+
function detailFor(node) {
|
|
108
|
+
if (node.kind === "connector") {
|
|
109
|
+
return `${node.integrationSlug ?? "?"}.${node.actionSlug ?? "?"}`;
|
|
110
|
+
}
|
|
111
|
+
if (node.kind === "tool") {
|
|
112
|
+
const uuid = node.toolUuid ?? configString(node, "toolUuid");
|
|
113
|
+
const slug = node.templateSlug ?? configString(node, "templateSlug");
|
|
114
|
+
return `tool ${uuid !== undefined && uuid !== null ? uuid.slice(0, 8) : (slug ?? "?")}`;
|
|
115
|
+
}
|
|
116
|
+
if (node.kind === "agent") {
|
|
117
|
+
const uuid = node.agentUuid ?? configString(node, "agentUuid");
|
|
118
|
+
const slug = node.templateSlug ?? configString(node, "templateSlug");
|
|
119
|
+
return `agent ${uuid !== undefined && uuid !== null ? uuid.slice(0, 8) : (slug ?? "?")}`;
|
|
120
|
+
}
|
|
121
|
+
if (node.kind === "native") {
|
|
122
|
+
const action = node.actionSlug ?? "";
|
|
123
|
+
if (action === "start" || action === "end")
|
|
124
|
+
return undefined;
|
|
125
|
+
if (action === "delay") {
|
|
126
|
+
const minutes = configNumber(node, "minutes");
|
|
127
|
+
return minutes !== undefined ? `delay ${String(minutes)}m` : "delay";
|
|
128
|
+
}
|
|
129
|
+
return action.length > 0 ? action : undefined;
|
|
130
|
+
}
|
|
131
|
+
return node.kind ?? undefined;
|
|
132
|
+
}
|
|
133
|
+
/** What the node is, in words a user recognises. */
|
|
134
|
+
export function labelFor(node) {
|
|
135
|
+
const headline = node.name !== null && node.name !== undefined && node.name.trim().length > 0
|
|
136
|
+
? node.name.trim()
|
|
137
|
+
: (node.slug ?? node.kind ?? "node");
|
|
138
|
+
const lines = [truncate(headline)];
|
|
139
|
+
const detail = detailFor(node);
|
|
140
|
+
if (detail !== undefined && detail !== lines[0])
|
|
141
|
+
lines.push(truncate(detail));
|
|
142
|
+
return lines.map(escapeLabel).join("<br/>");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* `childrenUuids` order carries the routing semantics — index 0 of a `branch`
|
|
146
|
+
* is the matched path, index 1 the unmatched one. Getting this backwards
|
|
147
|
+
* inverts the diagram's meaning, so it is table-driven, never inferred.
|
|
148
|
+
*/
|
|
149
|
+
function edgeLabels(node, count) {
|
|
150
|
+
const action = node.kind === "native" ? (node.actionSlug ?? "") : "";
|
|
151
|
+
if (action === "branch")
|
|
152
|
+
return ["yes", "no"];
|
|
153
|
+
if (action === "humanReview")
|
|
154
|
+
return ["approve", "decline"];
|
|
155
|
+
if (action === "filter")
|
|
156
|
+
return ["if true"];
|
|
157
|
+
if (action === "split") {
|
|
158
|
+
const pct = configNumber(node, "percentage");
|
|
159
|
+
if (pct === undefined)
|
|
160
|
+
return ["A", "B"];
|
|
161
|
+
return [`A ${String(pct)}%`, `B ${String(100 - pct)}%`];
|
|
162
|
+
}
|
|
163
|
+
if (action === "switch" || action === "balance") {
|
|
164
|
+
const rawRoutes = node.config?.["routes"];
|
|
165
|
+
const routes = Array.isArray(rawRoutes) ? rawRoutes : [];
|
|
166
|
+
return Array.from({ length: count }, (_, index) => {
|
|
167
|
+
// A switch often has one more child than routes — the default/else path.
|
|
168
|
+
if (index >= routes.length)
|
|
169
|
+
return "default";
|
|
170
|
+
const route = routes[index];
|
|
171
|
+
const name = route !== null && typeof route === "object" && "name" in route
|
|
172
|
+
? route.name
|
|
173
|
+
: undefined;
|
|
174
|
+
return typeof name === "string" ? name : `route ${String(index + 1)}`;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return Array.from({ length: count }, () => undefined);
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Rendering
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
function matches(node, needles) {
|
|
183
|
+
return needles.some((needle) => needle === node.uuid || needle === (node.slug ?? undefined));
|
|
184
|
+
}
|
|
185
|
+
function subNodes(node) {
|
|
186
|
+
const nested = node.config?.["_nodes"];
|
|
187
|
+
return Array.isArray(nested) && nested.length > 0
|
|
188
|
+
? nested
|
|
189
|
+
: undefined;
|
|
190
|
+
}
|
|
191
|
+
function renderNodes(nodes, context) {
|
|
192
|
+
const warnings = [];
|
|
193
|
+
const highlighted = [];
|
|
194
|
+
const direction = context.direction;
|
|
195
|
+
const byUuid = new Map();
|
|
196
|
+
for (const node of nodes) {
|
|
197
|
+
if (typeof node.uuid === "string")
|
|
198
|
+
byUuid.set(node.uuid, node);
|
|
199
|
+
}
|
|
200
|
+
// Ids come from a breadth-first walk from `start`, so the diagram reads in
|
|
201
|
+
// execution order and is stable across runs. Slugs are never used as ids:
|
|
202
|
+
// they repeat within a single release.
|
|
203
|
+
const order = [];
|
|
204
|
+
const ids = new Map();
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const queue = [];
|
|
207
|
+
const start = nodes.find((node) => node.kind === "native" && node.actionSlug === "start") ?? nodes[0];
|
|
208
|
+
if (start !== undefined)
|
|
209
|
+
queue.push(start.uuid);
|
|
210
|
+
while (queue.length > 0) {
|
|
211
|
+
const uuid = queue.shift();
|
|
212
|
+
if (uuid === undefined || seen.has(uuid))
|
|
213
|
+
continue;
|
|
214
|
+
const node = byUuid.get(uuid);
|
|
215
|
+
if (node === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
seen.add(uuid);
|
|
218
|
+
const id = `${context.idPrefix}n${String(order.length)}`;
|
|
219
|
+
ids.set(uuid, id);
|
|
220
|
+
order.push({ id, node });
|
|
221
|
+
for (const child of node.childrenUuids ?? []) {
|
|
222
|
+
if (child !== null && child !== undefined)
|
|
223
|
+
queue.push(child);
|
|
224
|
+
}
|
|
225
|
+
if (node.fallbackChildUuid !== null &&
|
|
226
|
+
node.fallbackChildUuid !== undefined) {
|
|
227
|
+
queue.push(node.fallbackChildUuid);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const orphans = nodes.filter((node) => typeof node.uuid === "string" && !seen.has(node.uuid));
|
|
231
|
+
for (const node of orphans) {
|
|
232
|
+
const id = `${context.idPrefix}n${String(order.length)}`;
|
|
233
|
+
ids.set(node.uuid, id);
|
|
234
|
+
order.push({ id, node });
|
|
235
|
+
}
|
|
236
|
+
if (orphans.length > 0) {
|
|
237
|
+
warnings.push(`unreachable from start: ${orphans.map((node) => node.slug ?? node.uuid).join(", ")} — these nodes never run`);
|
|
238
|
+
}
|
|
239
|
+
const edges = [];
|
|
240
|
+
const dangling = new Set();
|
|
241
|
+
for (const { id, node } of order) {
|
|
242
|
+
const children = node.childrenUuids ?? [];
|
|
243
|
+
const labels = edgeLabels(node, children.length);
|
|
244
|
+
children.forEach((child, index) => {
|
|
245
|
+
const target = child !== null && child !== undefined ? ids.get(child) : undefined;
|
|
246
|
+
if (target === undefined) {
|
|
247
|
+
dangling.add(node.slug ?? id);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
edges.push({ from: id, to: target, label: labels[index] });
|
|
251
|
+
});
|
|
252
|
+
// A fallback pointing at the node's own next step means "a failure here
|
|
253
|
+
// does not stop the run" — the same arrow, not a second one. Drawing it
|
|
254
|
+
// twice clutters the graph, so it becomes a ↷ marker on the label instead.
|
|
255
|
+
const fallbackUuid = node.fallbackChildUuid;
|
|
256
|
+
if (fallbackUuid !== null && fallbackUuid !== undefined) {
|
|
257
|
+
const target = ids.get(fallbackUuid);
|
|
258
|
+
if (target !== undefined && !children.includes(fallbackUuid)) {
|
|
259
|
+
edges.push({ from: id, to: target, label: "on failure", dashed: true });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (dangling.size > 0) {
|
|
264
|
+
warnings.push(`dangling childrenUuids (null or unknown) on: ${[...dangling].join(", ")} — the graph stops there`);
|
|
265
|
+
}
|
|
266
|
+
const lines = [];
|
|
267
|
+
for (const { id, node } of order) {
|
|
268
|
+
const [open, close] = shapeFor(node);
|
|
269
|
+
const paid = matches(node, context.paid) ? "💳 " : "";
|
|
270
|
+
if (matches(node, context.highlight))
|
|
271
|
+
highlighted.push(id);
|
|
272
|
+
const children = node.childrenUuids ?? [];
|
|
273
|
+
const continuesOnFailure = node.fallbackChildUuid !== null &&
|
|
274
|
+
node.fallbackChildUuid !== undefined &&
|
|
275
|
+
children.includes(node.fallbackChildUuid);
|
|
276
|
+
lines.push(` ${id}${open}"${paid}${labelFor(node)}${continuesOnFailure ? " ↷" : ""}"${close}`);
|
|
277
|
+
// A group's steps are drawn inside a sub-graph, under an id namespace of
|
|
278
|
+
// their own — `--paid` and `--highlight` apply just as they do out here,
|
|
279
|
+
// since a billing step buried in a loop is exactly the one worth seeing.
|
|
280
|
+
const nested = subNodes(node);
|
|
281
|
+
if (nested !== undefined) {
|
|
282
|
+
const inner = renderNodes(nested, { ...context, idPrefix: `${id}s` });
|
|
283
|
+
warnings.push(...inner.warnings.map((w) => `${node.slug ?? id}: ${w}`));
|
|
284
|
+
highlighted.push(...inner.highlighted);
|
|
285
|
+
lines.push(` subgraph ${id}_sub["per item"]`);
|
|
286
|
+
lines.push(` direction ${direction}`);
|
|
287
|
+
lines.push(...inner.lines.map((line) => ` ${line}`));
|
|
288
|
+
lines.push(" end");
|
|
289
|
+
lines.push(` ${id} --> ${id}_sub`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const edge of edges) {
|
|
293
|
+
const arrow = edge.dashed === true
|
|
294
|
+
? edge.label !== undefined
|
|
295
|
+
? `-. ${edge.label} .->`
|
|
296
|
+
: "-.->"
|
|
297
|
+
: edge.label !== undefined
|
|
298
|
+
? `-->|${escapeLabel(edge.label)}|`
|
|
299
|
+
: "-->";
|
|
300
|
+
lines.push(` ${edge.from} ${arrow} ${edge.to}`);
|
|
301
|
+
}
|
|
302
|
+
return { lines, warnings, highlighted };
|
|
303
|
+
}
|
|
304
|
+
export function renderNodeDiagram(nodes, options = {}) {
|
|
305
|
+
const direction = options.direction ?? "TD";
|
|
306
|
+
const lines = [];
|
|
307
|
+
if (options.title !== undefined && options.title.length > 0) {
|
|
308
|
+
// Quote the title if it contains YAML-special characters (colon-space, #,
|
|
309
|
+
// quotes, leading/trailing whitespace) to keep the frontmatter valid.
|
|
310
|
+
const raw = options.title.replace(/\n/g, " ");
|
|
311
|
+
const needsQuotes = /[:#"']|^\s|\s$/.test(raw);
|
|
312
|
+
const escaped = needsQuotes ? `"${raw.replace(/"/g, '\\"')}"` : raw;
|
|
313
|
+
lines.push("---", `title: ${escaped}`, "---");
|
|
314
|
+
}
|
|
315
|
+
lines.push(`flowchart ${direction}`);
|
|
316
|
+
const body = renderNodes(nodes, {
|
|
317
|
+
direction,
|
|
318
|
+
paid: options.paid ?? [],
|
|
319
|
+
highlight: options.highlight ?? [],
|
|
320
|
+
idPrefix: "",
|
|
321
|
+
});
|
|
322
|
+
lines.push(...body.lines);
|
|
323
|
+
// Declared once for the whole diagram, nested levels included — Mermaid takes
|
|
324
|
+
// a `classDef` name a single time.
|
|
325
|
+
if (body.highlighted.length > 0) {
|
|
326
|
+
lines.push(" classDef failing fill:#fee,stroke:#c00,stroke-width:2px");
|
|
327
|
+
lines.push(` class ${body.highlighted.join(",")} failing`);
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
diagram: lines.join("\n"),
|
|
331
|
+
format: "mermaid",
|
|
332
|
+
warnings: body.warnings,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Locating the graph in a payload
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
/** Node-shaped means: a uuid plus the fields a graph is walked by. */
|
|
339
|
+
export function isNodeArray(value) {
|
|
340
|
+
return (Array.isArray(value) &&
|
|
341
|
+
value.length > 0 &&
|
|
342
|
+
value.every((item) => item !== null &&
|
|
343
|
+
typeof item === "object" &&
|
|
344
|
+
typeof item.uuid === "string" &&
|
|
345
|
+
("childrenUuids" in item || "kind" in item)));
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Pull the graph out of a CLI payload: `release get`, `release get-draft`,
|
|
349
|
+
* `template get`, an ad-hoc `run get`, or a bare node array.
|
|
350
|
+
*/
|
|
351
|
+
export function extractNodes(payload) {
|
|
352
|
+
if (isNodeArray(payload))
|
|
353
|
+
return payload;
|
|
354
|
+
if (payload === null || typeof payload !== "object")
|
|
355
|
+
return undefined;
|
|
356
|
+
const record = payload;
|
|
357
|
+
const containers = [
|
|
358
|
+
record["nodes"],
|
|
359
|
+
record["release"]?.["nodes"],
|
|
360
|
+
record["draftRelease"]?.["nodes"],
|
|
361
|
+
record["template"]?.["nodes"],
|
|
362
|
+
record["run"]?.["nodes"],
|
|
363
|
+
];
|
|
364
|
+
for (const container of containers) {
|
|
365
|
+
if (isNodeArray(container))
|
|
366
|
+
return container;
|
|
367
|
+
}
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
/** The fenced block, ready to paste into a message, a PR, or the docs. */
|
|
371
|
+
export function toMermaidBlock(diagram) {
|
|
372
|
+
return `\`\`\`mermaid\n${diagram}\n\`\`\``;
|
|
373
|
+
}
|