@cargo-ai/cli 1.0.55 → 1.0.56

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.
@@ -0,0 +1,42 @@
1
+ export declare function displayWidth(text: string): number;
2
+ /**
3
+ * A rectangle of text plus the column its spine sits on. `spine` is where an
4
+ * incoming `│` attaches at the top and where an outgoing one leaves at the
5
+ * bottom, so stacking is a matter of lining up that column.
6
+ */
7
+ export type Block = {
8
+ lines: string[];
9
+ width: number;
10
+ spine: number;
11
+ };
12
+ export declare function padTo(line: string, width: number): string;
13
+ export declare function indent(block: Block, by: number): Block;
14
+ /** Text lines centred on a shared spine. No lines is a legitimate answer — an
15
+ * unnamed routing node draws as its split and nothing else. */
16
+ export declare function textBlock(texts: string[]): Block;
17
+ /** One row carrying nothing but the spine. */
18
+ export declare function spineBlock(char?: string): Block;
19
+ export declare function emptyBlock(): Block;
20
+ /** Stack blocks vertically, aligning their spines. */
21
+ export declare function vstack(blocks: Block[]): Block;
22
+ /** Place blocks side by side, returning where each one's spine landed. */
23
+ export declare function hstack(blocks: Block[], gap: number): {
24
+ block: Block;
25
+ spines: number[];
26
+ };
27
+ /** Extend a column downwards so every column in a split ends level. */
28
+ export declare function padColumn(block: Block, toHeight: number, char: string): Block;
29
+ /**
30
+ * The glyph where a rail meets the spine. A tee has to point at the rails it
31
+ * actually carries: drawing `├` while the rail leaves to the left produces a
32
+ * junction with an arm attached to nothing and a rail attached to nothing.
33
+ */
34
+ export declare function teeAt(spine: number, others: number[], down: boolean): string;
35
+ /** Draw a horizontal rail across `width`, writing one glyph per marked column. */
36
+ export declare function rail(width: number, marks: {
37
+ at: number;
38
+ char: string;
39
+ }[]): string;
40
+ /** Wrap a block in a captioned box, for a sub-flow that runs inside a step. */
41
+ export declare function frame(block: Block, caption: string): Block;
42
+ //# sourceMappingURL=asciiBlocks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asciiBlocks.d.ts","sourceRoot":"","sources":["../../../src/commands/orchestration/asciiBlocks.ts"],"names":[],"mappings":"AAyBA,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKjD;AAMD;;;;GAIG;AACH,MAAM,MAAM,KAAK,GAAG;IAClB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAIF,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAGzD;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,GAAG,KAAK,CAQtD;AAED;+DAC+D;AAC/D,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,KAAK,CAShD;AAED,8CAA8C;AAC9C,wBAAgB,UAAU,CAAC,IAAI,SAAM,GAAG,KAAK,CAE5C;AAED,wBAAgB,UAAU,IAAI,KAAK,CAElC;AAED,sDAAsD;AACtD,wBAAgB,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,KAAK,CAsB7C;AAED,0EAA0E;AAC1E,wBAAgB,MAAM,CACpB,MAAM,EAAE,KAAK,EAAE,EACf,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CA6BpC;AAED,uEAAuE;AACvE,wBAAgB,SAAS,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAQ7E;AAED;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAO5E;AAED,kFAAkF;AAClF,wBAAgB,IAAI,CAClB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,GACpC,MAAM,CAWR;AAED,+EAA+E;AAC/E,wBAAgB,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAU1D"}
@@ -0,0 +1,170 @@
1
+ // Text blocks: the layout algebra the ASCII diagram is assembled from.
2
+ //
3
+ // A Block is a rectangle of text plus the column its spine sits on — the column
4
+ // an incoming `│` attaches to at the top and an outgoing one leaves from at the
5
+ // bottom. Stacking is then a matter of lining that column up, which is what lets
6
+ // the renderer compose a drawing without ever tracking absolute coordinates.
7
+ //
8
+ // Nothing here knows what a workflow is. The invariant every producer maintains
9
+ // is that each line of a Block is exactly `block.width` cells wide, which is why
10
+ // so little of this has to measure anything.
11
+ // ---------------------------------------------------------------------------
12
+ // Character width
13
+ // ---------------------------------------------------------------------------
14
+ // A marker glyph occupies two terminal cells; measuring it as one silently
15
+ // shears every line to its right. `Emoji_Presentation` is the property that
16
+ // means "wide by default" — `Extended_Pictographic` is too broad, matching `◀`,
17
+ // which a terminal draws in a single cell.
18
+ const WIDE = /\p{Emoji_Presentation}/u;
19
+ // Most lines are plain text; the per-character scan is only needed once a line
20
+ // actually carries a marker. One test for the whole string skips it.
21
+ const PLAIN = /^[\x20-\x7E]*$/;
22
+ export function displayWidth(text) {
23
+ if (PLAIN.test(text))
24
+ return text.length;
25
+ let total = 0;
26
+ for (const char of text)
27
+ total += WIDE.test(char) ? 2 : 1;
28
+ return total;
29
+ }
30
+ const blank = (width) => (width > 0 ? " ".repeat(width) : "");
31
+ export function padTo(line, width) {
32
+ const short = width - displayWidth(line);
33
+ return short > 0 ? line + " ".repeat(short) : line;
34
+ }
35
+ export function indent(block, by) {
36
+ if (by <= 0)
37
+ return block;
38
+ const prefix = " ".repeat(by);
39
+ return {
40
+ lines: block.lines.map((line) => prefix + line),
41
+ width: block.width + by,
42
+ spine: block.spine + by,
43
+ };
44
+ }
45
+ /** Text lines centred on a shared spine. No lines is a legitimate answer — an
46
+ * unnamed routing node draws as its split and nothing else. */
47
+ export function textBlock(texts) {
48
+ if (texts.length === 0)
49
+ return emptyBlock();
50
+ const measured = texts.map((text) => ({ text, own: displayWidth(text) }));
51
+ const width = Math.max(...measured.map((entry) => entry.own));
52
+ const lines = measured.map(({ text, own }) => {
53
+ const left = Math.floor((width - own) / 2);
54
+ return " ".repeat(left) + text + blank(width - own - left);
55
+ });
56
+ return { lines, width, spine: Math.floor((width - 1) / 2) };
57
+ }
58
+ /** One row carrying nothing but the spine. */
59
+ export function spineBlock(char = "│") {
60
+ return { lines: [char], width: 1, spine: 0 };
61
+ }
62
+ export function emptyBlock() {
63
+ return { lines: [], width: 0, spine: 0 };
64
+ }
65
+ /** Stack blocks vertically, aligning their spines. */
66
+ export function vstack(blocks) {
67
+ const real = blocks.filter((block) => block.lines.length > 0);
68
+ if (real.length === 0)
69
+ return emptyBlock();
70
+ if (real.length === 1) {
71
+ const only = real[0];
72
+ if (only === undefined)
73
+ return emptyBlock();
74
+ return only;
75
+ }
76
+ const spine = Math.max(...real.map((block) => block.spine));
77
+ const shifted = real.map((block) => indent(block, spine - block.spine));
78
+ const width = Math.max(...shifted.map((block) => block.width));
79
+ return {
80
+ lines: shifted.flatMap((block) => {
81
+ const short = blank(width - block.width);
82
+ return short.length === 0
83
+ ? block.lines
84
+ : block.lines.map((line) => line + short);
85
+ }),
86
+ width,
87
+ spine,
88
+ };
89
+ }
90
+ /** Place blocks side by side, returning where each one's spine landed. */
91
+ export function hstack(blocks, gap) {
92
+ const height = Math.max(...blocks.map((block) => block.lines.length));
93
+ const spines = [];
94
+ let offset = 0;
95
+ const rows = Array.from({ length: height }, () => "");
96
+ blocks.forEach((block, index) => {
97
+ if (index > 0)
98
+ offset += gap;
99
+ spines.push(offset + block.spine);
100
+ for (let row = 0; row < height; row += 1) {
101
+ // Every Block keeps `displayWidth(line) === block.width` on every line, so
102
+ // the line needs no measuring here — only the canvas does.
103
+ const canvas = rows[row];
104
+ const line = block.lines[row];
105
+ rows[row] =
106
+ padTo(canvas === undefined ? "" : canvas, offset) +
107
+ (line === undefined ? blank(block.width) : line);
108
+ }
109
+ offset += block.width;
110
+ });
111
+ return {
112
+ block: {
113
+ lines: rows.map((row) => padTo(row, offset)),
114
+ width: offset,
115
+ spine: 0,
116
+ },
117
+ spines,
118
+ };
119
+ }
120
+ /** Extend a column downwards so every column in a split ends level. */
121
+ export function padColumn(block, toHeight, char) {
122
+ const missing = toHeight - block.lines.length;
123
+ if (missing <= 0)
124
+ return block;
125
+ const filler = padTo(" ".repeat(block.spine) + char, block.width);
126
+ return {
127
+ ...block,
128
+ lines: [...block.lines, ...Array.from({ length: missing }, () => filler)],
129
+ };
130
+ }
131
+ /**
132
+ * The glyph where a rail meets the spine. A tee has to point at the rails it
133
+ * actually carries: drawing `├` while the rail leaves to the left produces a
134
+ * junction with an arm attached to nothing and a rail attached to nothing.
135
+ */
136
+ export function teeAt(spine, others, down) {
137
+ const left = others.some((at) => at < spine);
138
+ const right = others.some((at) => at > spine);
139
+ if (left && right)
140
+ return "┼";
141
+ if (left)
142
+ return "┤";
143
+ if (right)
144
+ return "├";
145
+ return down ? "┬" : "┴";
146
+ }
147
+ /** Draw a horizontal rail across `width`, writing one glyph per marked column. */
148
+ export function rail(width, marks) {
149
+ const sorted = [...marks].sort((a, b) => a.at - b.at);
150
+ const first = sorted[0];
151
+ const last = sorted[sorted.length - 1];
152
+ if (first === undefined || last === undefined)
153
+ return " ".repeat(width);
154
+ const cells = Array.from({ length: width }, (_, column) => column > first.at && column < last.at ? "─" : " ");
155
+ for (const mark of sorted)
156
+ cells[mark.at] = mark.char;
157
+ return cells.join("");
158
+ }
159
+ /** Wrap a block in a captioned box, for a sub-flow that runs inside a step. */
160
+ export function frame(block, caption) {
161
+ const inner = Math.max(block.width, displayWidth(caption) + 2);
162
+ const width = inner + 4;
163
+ const label = ` ${caption} `;
164
+ const lines = [
165
+ `┌─${label}${"─".repeat(Math.max(0, width - 3 - displayWidth(label)))}┐`,
166
+ ...block.lines.map((line) => `│ ${padTo(line, inner)} │`),
167
+ `└${"─".repeat(width - 2)}┘`,
168
+ ];
169
+ return { lines, width, spine: block.spine + 2 };
170
+ }
@@ -1 +1 @@
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
+ {"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;AAgBxC,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,GAAG,IAAI,CAoK7E"}
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { ExitCodes, failWith, handleApiCall, outputJson, parseJson, } from "../runHandler.js";
3
3
  import { extractNodes, renderNodeDiagram, toMermaidBlock, } from "./nodeDiagram.js";
4
+ import { renderNodeAscii } from "./nodeDiagramAscii.js";
4
5
  export function registerNodeCommands(parent, getApi) {
5
6
  const node = parent
6
7
  .command("node")
@@ -105,7 +106,7 @@ Examples:
105
106
  function registerDiagramCommand(node, getApi) {
106
107
  node
107
108
  .command("diagram")
108
- .description("Draw a node graph as a Mermaid flowchart (free, runs nothing, no credits)")
109
+ .description("Draw a node graph ASCII for a terminal, Mermaid to paste elsewhere (free, runs nothing, no credits)")
109
110
  .option("--nodes <json>", "Node definitions (JSON array, or '-' to read from stdin)")
110
111
  .option("--file <path>", "Read a JSON payload or node array from a file")
111
112
  .option("--workflow-uuid <uuid>", "Diagram a workflow's deployed release (add --draft for its draft)")
@@ -113,37 +114,74 @@ function registerDiagramCommand(node, getApi) {
113
114
  .option("--release-uuid <uuid>", "Diagram a specific release")
114
115
  .option("--run-uuid <uuid>", "Diagram the graph a run executed (follows the run's release when it has one)")
115
116
  .option("--title <text>", "Title rendered above the diagram")
116
- .option("--direction <TD|LR>", "Flow direction (default: TD)", "TD")
117
+ .option("--format <ascii|mermaid>", "ascii = readable in a terminal · mermaid = paste into a PR, docs or a page (default: mermaid)", "mermaid")
118
+ .option("--direction <TD|LR>", "Mermaid flow direction, ignored by --format ascii (default: TD)", "TD")
117
119
  .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
+ .option("--highlight <slugs>", "Comma-separated node slugs/uuids to mark (red in Mermaid, in ASCII)")
121
+ .option("--raw", "Print the diagram itself instead of JSON")
120
122
  .addHelpText("after", `
121
123
  Free, runs nothing: turns a node graph into a picture so a reviewer can see the
122
124
  routing, the fallback paths and which steps bill before approving or deploying
123
125
  it. Pass exactly one source.
124
126
 
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.
127
+ Which format:
128
+ --format ascii SHOWING a graph to someone at a terminal. Renders as a
129
+ centred flow with named steps and labelled branches, so it
130
+ is readable as-is. Use this whenever you are about to
131
+ explain what a workflow, tool or play does.
132
+ --format ascii resolves step names from the integration catalog, so a step
133
+ reads 'Apollo.io / Enrich person'. --nodes and --file skip that lookup and
134
+ keep the slugs, so they work with no credential at all.
135
+
136
+ --format mermaid PASTING the graph somewhere that renders it — a PR body, a
137
+ markdown doc, a published page. In a terminal it is source
138
+ code, not a picture. This is the default, for
139
+ compatibility.
140
+
141
+ Returns {"diagram":"…","format":"ascii"|"mermaid","warnings":[…]}, or the
142
+ diagram itself with --raw (fenced for mermaid, plain text for ascii).
143
+ 'warnings' reports structural problems worth saying out loud — nodes
144
+ unreachable from start, dangling childrenUuids.
128
145
 
129
146
  A run created by 'action execute' carries its own graph; a run of a deployed
130
147
  tool or play carries only a releaseUuid, and --run-uuid follows it either way.
131
148
 
132
149
  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 -`)
150
+ $ cargo-ai orchestration node diagram --workflow-uuid <uuid> --format ascii --raw
151
+ $ cargo-ai orchestration node diagram --workflow-uuid <uuid> --draft --paid enrich,verify --format ascii --raw
152
+ $ cargo-ai orchestration node diagram --run-uuid <uuid> --highlight branch_1 --format ascii --raw
153
+ $ cargo-ai orchestration node diagram --workflow-uuid <uuid> --raw # mermaid, for a PR
154
+ $ cargo-ai orchestration release get-deployed --workflow-uuid <uuid> | cargo-ai orchestration node diagram --nodes - --format ascii --raw`)
137
155
  .action(async (opts) => {
156
+ const format = (opts.format === undefined ? "mermaid" : opts.format).toLowerCase();
157
+ if (format !== "ascii" && format !== "mermaid") {
158
+ failWith(`--format must be 'ascii' or 'mermaid', got '${format}'`, {
159
+ code: ExitCodes.InvalidUsage,
160
+ });
161
+ }
138
162
  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
- });
163
+ const result = format === "ascii"
164
+ ? renderNodeAscii(nodes, {
165
+ title: opts.title,
166
+ paid: splitList(opts.paid),
167
+ highlight: splitList(opts.highlight),
168
+ // `--nodes` / `--file` are the offline sources: resolving them
169
+ // touched no API, and reaching for one now to prettify a label
170
+ // would make an offline command demand a login.
171
+ names: opts.nodes === undefined && opts.file === undefined
172
+ ? await catalogNames(getApi, nodes)
173
+ : undefined,
174
+ })
175
+ : renderNodeDiagram(nodes, {
176
+ title: opts.title,
177
+ direction: (opts.direction === undefined
178
+ ? "TD"
179
+ : opts.direction).toUpperCase(),
180
+ paid: splitList(opts.paid),
181
+ highlight: splitList(opts.highlight),
182
+ });
145
183
  if (opts.raw === true) {
146
- console.log(toMermaidBlock(result.diagram));
184
+ console.log(format === "ascii" ? result.diagram : toMermaidBlock(result.diagram));
147
185
  for (const warning of result.warnings)
148
186
  console.error(`⚠ ${warning}`);
149
187
  return;
@@ -151,6 +189,81 @@ Examples:
151
189
  outputJson(result);
152
190
  });
153
191
  }
192
+ /**
193
+ * Display names for the steps a graph actually uses, so it reads
194
+ * `Apollo.io` / `Enrich person` rather than `apolloio` / `enrichPerson`.
195
+ *
196
+ * Both halves come from the platform's own catalogs — the integration catalog
197
+ * for connector steps, the native-integration catalog for built-in ones — so a
198
+ * step is never labelled with a name this CLI invented. Renaming an action on
199
+ * the platform reaches the drawing without a code change.
200
+ *
201
+ * Best-effort, and only for graphs that came from the API in the first place.
202
+ * `--nodes` and `--file` skip it entirely: those resolve without a credential,
203
+ * and `getApi()` exits the process when there is none, so asking for names
204
+ * there would turn an offline command into one that demands a login. A catalog
205
+ * that is reachable but fails — a token without the scope, a network blip —
206
+ * falls back to slugs rather than losing the drawing.
207
+ */
208
+ async function catalogNames(getApi, nodes) {
209
+ const used = new Set();
210
+ let hasNative = false;
211
+ // Group bodies live in `config._nodes` and are drawn too, so their steps need
212
+ // names as much as the outer ones — otherwise a loop shows wire slugs beside
213
+ // an outer graph showing product names.
214
+ const collect = (list) => {
215
+ for (const node of list) {
216
+ if (node.kind === "connector" &&
217
+ typeof node.integrationSlug === "string") {
218
+ used.add(node.integrationSlug);
219
+ }
220
+ if (node.kind === "native")
221
+ hasNative = true;
222
+ const config = node.config;
223
+ if (config !== null && config !== undefined) {
224
+ const nested = config["_nodes"];
225
+ if (Array.isArray(nested))
226
+ collect(nested);
227
+ }
228
+ }
229
+ };
230
+ collect(nodes);
231
+ if (used.size === 0 && !hasNative)
232
+ return undefined;
233
+ try {
234
+ const api = getApi();
235
+ const [integrations, native] = await Promise.all([
236
+ used.size === 0
237
+ ? undefined
238
+ : api.connection.integration
239
+ .list({ slugs: [...used] })
240
+ .catch(() => undefined),
241
+ hasNative
242
+ ? api.connection.nativeIntegration.get().catch(() => undefined)
243
+ : undefined,
244
+ ]);
245
+ const byIntegration = {};
246
+ const byAction = {};
247
+ const listed = integrations === undefined ? [] : integrations.integrations;
248
+ for (const integration of listed) {
249
+ const actions = integration.actions === undefined ? {} : integration.actions;
250
+ byIntegration[integration.slug] = integration.name;
251
+ byAction[integration.slug] = Object.fromEntries(Object.entries(actions).map(([slug, action]) => [slug, action.name]));
252
+ }
253
+ const nativeActions = native === undefined ? {} : native.nativeIntegration.actions;
254
+ return {
255
+ integrations: byIntegration,
256
+ actions: byAction,
257
+ native: Object.fromEntries(Object.entries(nativeActions).map(([slug, action]) => [
258
+ slug,
259
+ action.name,
260
+ ])),
261
+ };
262
+ }
263
+ catch {
264
+ return undefined;
265
+ }
266
+ }
154
267
  function splitList(value) {
155
268
  if (value === undefined)
156
269
  return [];
@@ -30,8 +30,50 @@ export type DiagramResult = {
30
30
  /** Structural problems worth saying out loud rather than drawing over. */
31
31
  warnings: string[];
32
32
  };
33
+ /** Actions that route rather than do work. Shared, so adding one to the
34
+ * platform does not have to be mirrored in each renderer. */
35
+ export declare const ROUTING_ACTIONS: Set<string>;
36
+ export declare function isStart(node: DiagramNode | undefined): boolean;
37
+ export declare function isEnd(node: DiagramNode | undefined): boolean;
38
+ export declare function indexByUuid(nodes: DiagramNode[]): Map<string, DiagramNode>;
39
+ /** Where the graph begins — the `start` node, or the first node if it has none. */
40
+ export declare function findStart(nodes: DiagramNode[]): DiagramNode | undefined;
41
+ /**
42
+ * Whether a user-supplied `--paid` / `--highlight` token names this node.
43
+ * Slugs repeat within a release, so a slug token marks every node carrying it;
44
+ * pass a uuid to mark exactly one.
45
+ */
46
+ export declare function matchesNode(node: DiagramNode, needles: string[]): boolean;
47
+ /**
48
+ * A node's outgoing edges, in `childrenUuids` order, labelled by what the
49
+ * routing node means. Shared because this encodes routing *semantics* — index
50
+ * order, and the rule that a fallback pointing at the node's own next step is
51
+ * the same arrow rather than a second one. Two copies would let the two formats
52
+ * draw different graphs from one release.
53
+ */
54
+ /**
55
+ * A fallback pointing at the node's own next step: a failure here does not stop
56
+ * the run. It is not a second arrow — it is the same one — so it shows as a mark
57
+ * on the step rather than an edge. Silently dropping it makes a step that
58
+ * survives a provider outage look like one that dies on it, which is the exact
59
+ * misread this command exists to prevent.
60
+ */
61
+ export declare function continuesOnFailure(node: DiagramNode): boolean;
62
+ export declare function outgoingEdges(node: DiagramNode, byUuid: Map<string, DiagramNode>): {
63
+ to: string;
64
+ label?: string;
65
+ dashed?: boolean;
66
+ }[];
67
+ /** Structural problems worth saying out loud rather than drawing over. */
68
+ export declare function graphWarnings(nodes: DiagramNode[], byUuid: Map<string, DiagramNode>, reached: Set<string>): string[];
33
69
  /** What the node is, in words a user recognises. */
34
70
  export declare function labelFor(node: DiagramNode): string;
71
+ /**
72
+ * `childrenUuids` order carries the routing semantics — index 0 of a `branch`
73
+ * is the matched path, index 1 the unmatched one. Getting this backwards
74
+ * inverts the diagram's meaning, so it is table-driven, never inferred.
75
+ */
76
+ export declare function edgeLabels(node: DiagramNode, count: number): (string | undefined)[];
35
77
  export declare function renderNodeDiagram(nodes: DiagramNode[], options?: DiagramOptions): DiagramResult;
36
78
  /** Node-shaped means: a uuid plus the fields a graph is walked by. */
37
79
  export declare function isNodeArray(value: unknown): value is DiagramNode[];
@@ -1 +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"}
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;AAEF;6DAC6D;AAC7D,eAAO,MAAM,eAAe,aAO1B,CAAC;AAOH,wBAAgB,OAAO,CAAC,IAAI,EAAE,WAAW,GAAG,SAAS,GAAG,OAAO,CAG9D;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,WAAW,GAAG,SAAS,GAAG,OAAO,CAG5D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAM1E;AAED,mFAAmF;AACnF,wBAAgB,SAAS,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,WAAW,GAAG,SAAS,CAGvE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAGzE;AAED;;;;;;GAMG;AACH;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAI7D;AAQD,wBAAgB,aAAa,CAC3B,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAC/B;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAAE,CAmBpD;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAC3B,KAAK,EAAE,WAAW,EAAE,EACpB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,EAChC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,GACnB,MAAM,EAAE,CAyBV;AA4GD,oDAAoD;AACpD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,CASlD;AAQD;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,MAAM,GACZ,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAyBxB;AAoKD,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"}
@@ -13,24 +13,117 @@
13
13
  // * `childrenUuids` order carries the routing semantics (index 0 of a
14
14
  // `branch` is the matched path), so edge labels are table-driven rather
15
15
  // than inferred from node names.
16
- const ROUTING_ACTIONS = [
16
+ /** Actions that route rather than do work. Shared, so adding one to the
17
+ * platform does not have to be mirrored in each renderer. */
18
+ export const ROUTING_ACTIONS = new Set([
17
19
  "branch",
18
20
  "filter",
19
21
  "switch",
20
22
  "split",
21
23
  "balance",
22
24
  "humanReview",
23
- ];
25
+ ]);
24
26
  const CODE_ACTIONS = ["python", "script"];
25
27
  // ---------------------------------------------------------------------------
28
+ // Graph basics, shared by every renderer
29
+ // ---------------------------------------------------------------------------
30
+ export function isStart(node) {
31
+ if (node === undefined)
32
+ return false;
33
+ return node.kind === "native" && node.actionSlug === "start";
34
+ }
35
+ export function isEnd(node) {
36
+ if (node === undefined)
37
+ return false;
38
+ return node.kind === "native" && node.actionSlug === "end";
39
+ }
40
+ export function indexByUuid(nodes) {
41
+ const byUuid = new Map();
42
+ for (const node of nodes) {
43
+ if (typeof node.uuid === "string")
44
+ byUuid.set(node.uuid, node);
45
+ }
46
+ return byUuid;
47
+ }
48
+ /** Where the graph begins — the `start` node, or the first node if it has none. */
49
+ export function findStart(nodes) {
50
+ const start = nodes.find(isStart);
51
+ return start === undefined ? nodes[0] : start;
52
+ }
53
+ /**
54
+ * Whether a user-supplied `--paid` / `--highlight` token names this node.
55
+ * Slugs repeat within a release, so a slug token marks every node carrying it;
56
+ * pass a uuid to mark exactly one.
57
+ */
58
+ export function matchesNode(node, needles) {
59
+ const slug = node.slug === null ? undefined : node.slug;
60
+ return needles.some((needle) => needle === node.uuid || needle === slug);
61
+ }
62
+ /**
63
+ * A node's outgoing edges, in `childrenUuids` order, labelled by what the
64
+ * routing node means. Shared because this encodes routing *semantics* — index
65
+ * order, and the rule that a fallback pointing at the node's own next step is
66
+ * the same arrow rather than a second one. Two copies would let the two formats
67
+ * draw different graphs from one release.
68
+ */
69
+ /**
70
+ * A fallback pointing at the node's own next step: a failure here does not stop
71
+ * the run. It is not a second arrow — it is the same one — so it shows as a mark
72
+ * on the step rather than an edge. Silently dropping it makes a step that
73
+ * survives a provider outage look like one that dies on it, which is the exact
74
+ * misread this command exists to prevent.
75
+ */
76
+ export function continuesOnFailure(node) {
77
+ const fallback = node.fallbackChildUuid;
78
+ if (typeof fallback !== "string")
79
+ return false;
80
+ return childrenOf(node).includes(fallback);
81
+ }
82
+ /** A node's child slots, in order, including the empty ones. */
83
+ function childrenOf(node) {
84
+ const children = node.childrenUuids;
85
+ return children === null || children === undefined ? [] : children;
86
+ }
87
+ export function outgoingEdges(node, byUuid) {
88
+ const children = childrenOf(node);
89
+ const labels = edgeLabels(node, children.length);
90
+ const edges = [];
91
+ children.forEach((child, index) => {
92
+ if (typeof child === "string" && byUuid.has(child)) {
93
+ edges.push({ to: child, label: labels[index] });
94
+ }
95
+ });
96
+ const fallback = node.fallbackChildUuid;
97
+ if (typeof fallback === "string" &&
98
+ byUuid.has(fallback) &&
99
+ !children.includes(fallback)) {
100
+ edges.push({ to: fallback, label: "on failure", dashed: true });
101
+ }
102
+ return edges;
103
+ }
104
+ /** Structural problems worth saying out loud rather than drawing over. */
105
+ export function graphWarnings(nodes, byUuid, reached) {
106
+ const warnings = [];
107
+ const name = (node) => node.slug === null || node.slug === undefined ? node.uuid : node.slug;
108
+ const orphans = nodes.filter((node) => typeof node.uuid === "string" && !reached.has(node.uuid));
109
+ if (orphans.length > 0) {
110
+ warnings.push(`unreachable from start: ${orphans.map(name).join(", ")} — these nodes never run`);
111
+ }
112
+ const dangling = nodes.filter((node) => childrenOf(node).some((child) => child === null || byUuid.has(child) === false));
113
+ if (dangling.length > 0) {
114
+ warnings.push(`dangling childrenUuids (null or unknown) on: ${dangling.map(name).join(", ")} — the graph stops there`);
115
+ }
116
+ return warnings;
117
+ }
118
+ // ---------------------------------------------------------------------------
26
119
  // Shapes and labels
27
120
  // ---------------------------------------------------------------------------
28
121
  function shapeFor(node) {
29
122
  const action = node.actionSlug ?? "";
30
123
  if (node.kind === "native") {
31
- if (action === "start" || action === "end")
124
+ if (isStart(node) || isEnd(node))
32
125
  return ["([", "])"];
33
- if (ROUTING_ACTIONS.includes(action))
126
+ if (ROUTING_ACTIONS.has(action))
34
127
  return ["{", "}"];
35
128
  if (CODE_ACTIONS.includes(action))
36
129
  return ["[/", "/]"];
@@ -146,7 +239,7 @@ export function labelFor(node) {
146
239
  * is the matched path, index 1 the unmatched one. Getting this backwards
147
240
  * inverts the diagram's meaning, so it is table-driven, never inferred.
148
241
  */
149
- function edgeLabels(node, count) {
242
+ export function edgeLabels(node, count) {
150
243
  const action = node.kind === "native" ? (node.actionSlug ?? "") : "";
151
244
  if (action === "branch")
152
245
  return ["yes", "no"];
@@ -179,9 +272,6 @@ function edgeLabels(node, count) {
179
272
  // ---------------------------------------------------------------------------
180
273
  // Rendering
181
274
  // ---------------------------------------------------------------------------
182
- function matches(node, needles) {
183
- return needles.some((needle) => needle === node.uuid || needle === (node.slug ?? undefined));
184
- }
185
275
  function subNodes(node) {
186
276
  const nested = node.config?.["_nodes"];
187
277
  return Array.isArray(nested) && nested.length > 0
@@ -192,11 +282,7 @@ function renderNodes(nodes, context) {
192
282
  const warnings = [];
193
283
  const highlighted = [];
194
284
  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
- }
285
+ const byUuid = indexByUuid(nodes);
200
286
  // Ids come from a breadth-first walk from `start`, so the diagram reads in
201
287
  // execution order and is stable across runs. Slugs are never used as ids:
202
288
  // they repeat within a single release.
@@ -204,7 +290,7 @@ function renderNodes(nodes, context) {
204
290
  const ids = new Map();
205
291
  const seen = new Set();
206
292
  const queue = [];
207
- const start = nodes.find((node) => node.kind === "native" && node.actionSlug === "start") ?? nodes[0];
293
+ const start = findStart(nodes);
208
294
  if (start !== undefined)
209
295
  queue.push(start.uuid);
210
296
  while (queue.length > 0) {
@@ -236,28 +322,29 @@ function renderNodes(nodes, context) {
236
322
  if (orphans.length > 0) {
237
323
  warnings.push(`unreachable from start: ${orphans.map((node) => node.slug ?? node.uuid).join(", ")} — these nodes never run`);
238
324
  }
325
+ // Edges come from the shared walk, so both formats encode routing the same
326
+ // way — index order, and the rule that a fallback pointing at a node's own
327
+ // next step is a ↷ mark rather than a second arrow. Only the id mapping is
328
+ // Mermaid's own.
239
329
  const edges = [];
240
330
  const dangling = new Set();
241
331
  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
- }
332
+ for (const edge of outgoingEdges(node, byUuid)) {
333
+ const target = ids.get(edge.to);
334
+ if (target === undefined)
335
+ continue;
336
+ edges.push({
337
+ from: id,
338
+ to: target,
339
+ label: edge.label,
340
+ dashed: edge.dashed,
341
+ });
342
+ }
343
+ // `outgoingEdges` drops unusable slots by design; the diagram still has to
344
+ // report them, and it reports the node rather than the slot.
345
+ const hasUnusableSlot = childrenOf(node).some((child) => child === null || byUuid.has(child) === false);
346
+ if (hasUnusableSlot) {
347
+ dangling.add(node.slug === null || node.slug === undefined ? id : node.slug);
261
348
  }
262
349
  }
263
350
  if (dangling.size > 0) {
@@ -266,14 +353,10 @@ function renderNodes(nodes, context) {
266
353
  const lines = [];
267
354
  for (const { id, node } of order) {
268
355
  const [open, close] = shapeFor(node);
269
- const paid = matches(node, context.paid) ? "💳 " : "";
270
- if (matches(node, context.highlight))
356
+ const paid = matchesNode(node, context.paid) ? "💳 " : "";
357
+ if (matchesNode(node, context.highlight))
271
358
  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}`);
359
+ lines.push(` ${id}${open}"${paid}${labelFor(node)}${continuesOnFailure(node) ? " ↷" : ""}"${close}`);
277
360
  // A group's steps are drawn inside a sub-graph, under an id namespace of
278
361
  // their own — `--paid` and `--highlight` apply just as they do out here,
279
362
  // since a billing step buried in a loop is exactly the one worth seeing.
@@ -0,0 +1,32 @@
1
+ import { displayWidth } from "./asciiBlocks.js";
2
+ import { type DiagramNode } from "./nodeDiagram.js";
3
+ /**
4
+ * Display names from the integration catalog. A graph stores wire slugs
5
+ * (`apolloio`, `enrichPerson`); the reader knows the product by its name.
6
+ * Optional throughout, so a graph diagrammed offline still renders.
7
+ */
8
+ export type NameBook = {
9
+ /** integration slug → product name, e.g. `apolloio` → `Apollo.io`. */
10
+ integrations?: Record<string, string>;
11
+ /** integration slug → action slug → action name. */
12
+ actions?: Record<string, Record<string, string>>;
13
+ /** native action slug → action name, e.g. `script` → `JavaScript`. */
14
+ native?: Record<string, string>;
15
+ };
16
+ export type AsciiOptions = {
17
+ title?: string;
18
+ /** Catalog names, so steps read as products rather than as slugs. */
19
+ names?: NameBook;
20
+ /** Node slugs or uuids that bill credits — marked with 💳. */
21
+ paid?: string[];
22
+ /** Node slugs or uuids to mark, e.g. the failing node in a trace. */
23
+ highlight?: string[];
24
+ };
25
+ export { displayWidth };
26
+ export type AsciiResult = {
27
+ diagram: string;
28
+ format: "ascii";
29
+ warnings: string[];
30
+ };
31
+ export declare function renderNodeAscii(nodes: DiagramNode[], options?: AsciiOptions): AsciiResult;
32
+ //# sourceMappingURL=nodeDiagramAscii.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nodeDiagramAscii.d.ts","sourceRoot":"","sources":["../../../src/commands/orchestration/nodeDiagramAscii.ts"],"names":[],"mappings":"AA4BA,OAAO,EAEL,YAAY,EAWb,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAEL,KAAK,WAAW,EAQjB,MAAM,kBAAkB,CAAC;AAE1B;;;;GAIG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,sEAAsE;IACtE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,oDAAoD;IACpD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjD,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;CACtB,CAAC;AAEF,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAkkBF,wBAAgB,eAAe,CAC7B,KAAK,EAAE,WAAW,EAAE,EACpB,OAAO,GAAE,YAAiB,GACzB,WAAW,CAiDb"}
@@ -0,0 +1,528 @@
1
+ // Render a workflow node graph as a centred ASCII flow, for terminals.
2
+ //
3
+ // Mermaid is the right answer when the output lands somewhere that renders it —
4
+ // a PR, the docs, a published page. It is the wrong answer in a terminal, where
5
+ // it is just source code the reader has to compile in their head. This module
6
+ // draws the same graph as text a person can read where they are standing.
7
+ //
8
+ // Why not shell out to one of the mermaid-to-ASCII renderers: they re-parse the
9
+ // diagram and auto-route edges on a character grid, which collides as soon as a
10
+ // graph has crossings. Measured on the 29-node `Find email` waterfall, they
11
+ // produce rails drawn straight through node boxes, doubled edge labels
12
+ // (`├─noo►`), and junction characters overwriting label text (`Email┴valid?`) —
13
+ // a picture that reads as edges the workflow does not have. We never have to
14
+ // infer layout, because we hold the graph: this renderer lays a spine down the
15
+ // centre and hangs rails off it, so an edge it draws is an edge that exists.
16
+ //
17
+ // The shape it draws, and why:
18
+ //
19
+ // * A **spine** runs down the centre. One child continues it; the others peel
20
+ // off as rails.
21
+ // * A branch whose paths reconverge is a **detour**: the rail leaves at `├──┐`
22
+ // and rejoins at `├──┘`, and the spine keeps its identity across the split.
23
+ // * A branch whose paths never reconverge is a **fork**: `┌──┴──┐`, columns
24
+ // side by side, no spine survives.
25
+ //
26
+ // Which child holds the spine is the whole readability question, and it is not
27
+ // the same answer in both cases — see `chooseSpine`.
28
+ import { displayWidth, emptyBlock, frame, hstack, indent, padColumn, rail, spineBlock, teeAt, textBlock, vstack, } from "./asciiBlocks.js";
29
+ import { continuesOnFailure, findStart, graphWarnings, indexByUuid, isEnd, matchesNode, outgoingEdges, ROUTING_ACTIONS, } from "./nodeDiagram.js";
30
+ export { displayWidth };
31
+ /**
32
+ * Where the incoming line meets the split. A detour lands on the column that
33
+ * keeps the spine; a fork lands between the columns, snapped onto one when it
34
+ * falls beside it — `┴` and `┬` in adjacent cells read as a smudge rather than
35
+ * a junction.
36
+ */
37
+ function columnAnchor(spines, spineIndex, midpoint) {
38
+ if (spineIndex !== undefined) {
39
+ const at = spines[spineIndex];
40
+ return at === undefined ? 0 : at;
41
+ }
42
+ const nearest = spines.find((at) => Math.abs(at - midpoint) <= 1);
43
+ return nearest === undefined ? midpoint : nearest;
44
+ }
45
+ /** Whether a split column is still carrying the flow. */
46
+ function isColumnOpen(columns, index) {
47
+ if (index === undefined)
48
+ return false;
49
+ const column = columns[index];
50
+ return column === undefined ? false : column.open;
51
+ }
52
+ /** A node's outgoing edges, or none when it has no entry in the graph. */
53
+ function edgesFrom(graph, uuid) {
54
+ const edges = graph.out.get(uuid);
55
+ return edges === undefined ? [] : edges;
56
+ }
57
+ /** What to call a node when nothing better is available. */
58
+ function fallbackLabel(node) {
59
+ return node.slug === null || node.slug === undefined ? node.uuid : node.slug;
60
+ }
61
+ function buildGraph(nodes) {
62
+ const byUuid = indexByUuid(nodes);
63
+ const out = new Map();
64
+ for (const node of nodes)
65
+ out.set(node.uuid, outgoingEdges(node, byUuid));
66
+ return { byUuid, out, reach: new Map() };
67
+ }
68
+ /** Everything reachable from `uuid`, itself excluded. Cycle-safe, memoised. */
69
+ function reachable(graph, uuid) {
70
+ const cached = graph.reach.get(uuid);
71
+ if (cached !== undefined)
72
+ return cached;
73
+ const seen = new Set();
74
+ const stack = edgesFrom(graph, uuid).map((edge) => edge.to);
75
+ while (stack.length > 0) {
76
+ const next = stack.pop();
77
+ if (next === undefined || seen.has(next))
78
+ continue;
79
+ seen.add(next);
80
+ for (const edge of edgesFrom(graph, next))
81
+ stack.push(edge.to);
82
+ }
83
+ // A descendant pointing back adds the node to its own set. `chooseSpine`
84
+ // weighs arms by these sizes, so one extra entry is enough to hand the spine
85
+ // to an arm that should have stayed half of a fork.
86
+ seen.delete(uuid);
87
+ graph.reach.set(uuid, seen);
88
+ return seen;
89
+ }
90
+ /**
91
+ * Where a branch's paths come back together: the first node reachable from
92
+ * every child, in one child's own reach order so the choice is deterministic.
93
+ *
94
+ * A merge on an `end` node does not count. Every path in a workflow ends there,
95
+ * so treating it as a reconvergence would wrap the entire graph in one split
96
+ * and nest every later branch inside it.
97
+ */
98
+ function mergePoint(graph, children) {
99
+ const [first, ...rest] = children;
100
+ if (first === undefined || rest.length === 0)
101
+ return undefined;
102
+ // `reachable` hands back its memoised set, so the child itself is compared
103
+ // separately rather than added to it. Adding would poison the cache, and
104
+ // `chooseSpine` weighs branches by exactly those set sizes — an entry that
105
+ // grew by one flips the `best >= second * 2 + 1` test and picks the wrong
106
+ // arm to carry the spine.
107
+ const others = rest.map((child) => ({
108
+ child,
109
+ reaches: reachable(graph, child),
110
+ }));
111
+ const candidates = [first, ...reachable(graph, first)];
112
+ for (const candidate of candidates) {
113
+ if (isEnd(graph.byUuid.get(candidate)))
114
+ continue;
115
+ if (others.every(({ child, reaches }) => child === candidate || reaches.has(candidate))) {
116
+ return candidate;
117
+ }
118
+ }
119
+ return undefined;
120
+ }
121
+ /**
122
+ * Which child keeps the spine — the one judgement call in the layout, and the
123
+ * answer differs by branch kind:
124
+ *
125
+ * * **Detour** (the paths reconverge): the spine is the *straight-through*
126
+ * path, so source order wins and child 0 keeps it. A "not found → look it
127
+ * up another way → carry on" branch then reads as a short excursion off a
128
+ * continuous main line, which is what it is.
129
+ *
130
+ * * **Fork** (they never reconverge): source order is meaningless, because
131
+ * the two sides are not a main line and an excursion. The path with more
132
+ * work left in it is the main line. In a provider waterfall the "yes" exit
133
+ * is two nodes and the "no" carries the remaining twenty, so weighting by
134
+ * what is left keeps the waterfall vertical instead of stair-stepping it
135
+ * sideways once per stage.
136
+ *
137
+ * The fork rule only applies when one side is decisively bigger. Two ends of
138
+ * comparable weight are a genuine fork and get drawn symmetrically.
139
+ */
140
+ function chooseSpine(graph, edges, merged) {
141
+ if (merged)
142
+ return 0;
143
+ const weights = edges.map((edge) => reachable(graph, edge.to).size + 1);
144
+ let best = 0;
145
+ let second = 0;
146
+ let bestIndex = 0;
147
+ weights.forEach((weight, index) => {
148
+ if (weight > best) {
149
+ second = best;
150
+ best = weight;
151
+ bestIndex = index;
152
+ }
153
+ else if (weight > second) {
154
+ second = weight;
155
+ }
156
+ });
157
+ return best >= second * SPINE_DOMINANCE + 1 ? bestIndex : undefined;
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Layout
161
+ // ---------------------------------------------------------------------------
162
+ /** Past this the drawing wraps in an ordinary terminal and stops being one. */
163
+ const TERMINAL_WIDTH = 100;
164
+ /**
165
+ * How much bigger one arm of a non-reconverging branch must be before it is
166
+ * treated as the main line rather than one of two equal ends.
167
+ */
168
+ const SPINE_DOMINANCE = 2;
169
+ const GAP = 5;
170
+ /**
171
+ * `enrichPerson` → `Enrich person`. Only ever a fallback: the catalog's own name
172
+ * is better, and this is what a graph rendered without one falls back to.
173
+ * Splitting on case is safe for the slugs the platform mints; it deliberately
174
+ * does not touch integration slugs, where guessing turns `apolloio` into
175
+ * something that is neither the slug nor the product.
176
+ */
177
+ function prettyAction(slug) {
178
+ const words = slug
179
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
180
+ .replace(/[_-]+/g, " ")
181
+ .trim()
182
+ .toLowerCase();
183
+ const first = words[0];
184
+ return first === undefined ? slug : first.toUpperCase() + words.slice(1);
185
+ }
186
+ function marks(node, context) {
187
+ let suffix = "";
188
+ if (continuesOnFailure(node))
189
+ suffix += " ↷";
190
+ if (matchesNode(node, context.paid))
191
+ suffix += " 💳";
192
+ if (matchesNode(node, context.highlight))
193
+ suffix += " ←";
194
+ return suffix;
195
+ }
196
+ /**
197
+ * The two label lines: what system the step runs on, then what it does. That
198
+ * ordering is deliberate — scanning a flow, the reader wants the provider first
199
+ * ("Apollo", "Slack") and the specifics second.
200
+ */
201
+ function nodeLines(node, context) {
202
+ const suffix = marks(node, context);
203
+ const action = node.actionSlug === null || node.actionSlug === undefined
204
+ ? ""
205
+ : node.actionSlug;
206
+ if (node.kind === "native" && (action === "start" || action === "end")) {
207
+ return [`${fallbackLabel(node)}${suffix}`];
208
+ }
209
+ const name = node.name === null || node.name === undefined
210
+ ? undefined
211
+ : node.name.trim();
212
+ const book = context.names;
213
+ // The action's own name, as the platform publishes it. Falling back to the
214
+ // slug split into words keeps an offline drawing readable without this file
215
+ // inventing a name that disagrees with the product.
216
+ const published = book === undefined || book.native === undefined
217
+ ? undefined
218
+ : book.native[action];
219
+ const nativeName = published === undefined ? prettyAction(action) : published;
220
+ // A routing step's second line would read "Branch" under every one of them,
221
+ // so it gets one line. The labelled rails leaving it already say it routes.
222
+ if (node.kind === "native" && ROUTING_ACTIONS.has(action)) {
223
+ const headline = name !== undefined && name.length > 0 ? name : nativeName;
224
+ return [`${headline}${suffix}`];
225
+ }
226
+ if (node.kind === "connector") {
227
+ const slug = node.integrationSlug === null || node.integrationSlug === undefined
228
+ ? "connector"
229
+ : node.integrationSlug;
230
+ const named = book === undefined || book.integrations === undefined
231
+ ? undefined
232
+ : book.integrations[slug];
233
+ const provider = named === undefined ? slug : named;
234
+ const perAction = book === undefined || book.actions === undefined
235
+ ? undefined
236
+ : book.actions[slug];
237
+ const actionName = perAction === undefined ? undefined : perAction[action];
238
+ const what = name !== undefined && name.length > 0
239
+ ? name
240
+ : actionName === undefined
241
+ ? prettyAction(action)
242
+ : actionName;
243
+ return provider === what
244
+ ? [`${provider}${suffix}`]
245
+ : [`${provider}${suffix}`, what];
246
+ }
247
+ const provider = node.kind === "tool"
248
+ ? "Tool"
249
+ : node.kind === "agent"
250
+ ? "Agent"
251
+ : nativeName;
252
+ const headline = name !== undefined && name.length > 0 ? name : node.slug;
253
+ if (headline === undefined ||
254
+ headline === null ||
255
+ headline === provider ||
256
+ headline === action) {
257
+ return [`${provider}${suffix}`];
258
+ }
259
+ return [`${provider}${suffix}`, headline];
260
+ }
261
+ function referenceBlock(node, context) {
262
+ // The step's most specific line — for a two-line step that is what it does,
263
+ // not the product it runs on, since the product repeats across steps.
264
+ const lines = nodeLines(node, context);
265
+ const specific = lines[lines.length - 1];
266
+ const label = specific ?? fallbackLabel(node);
267
+ return textBlock([`↑ ${label}`]);
268
+ }
269
+ /**
270
+ * A group's own graph, drawn inside a box rather than left to the reader's
271
+ * imagination. The steps live in `config._nodes` and form a complete graph of
272
+ * their own — start node included — so the same walk draws them one level down.
273
+ * Marks and names carry in; `drawn` does not, because an inner step repeating an
274
+ * outer slug is a different step.
275
+ */
276
+ function subGraph(node, context) {
277
+ const config = node.config;
278
+ const nested = config === null || config === undefined ? undefined : config["_nodes"];
279
+ // No `_nodes` key at all means the graph came from a source that does not
280
+ // carry loop bodies; an empty one means the loop was left empty, which is a
281
+ // defect the drawing should not hide behind a tidy box.
282
+ if (!Array.isArray(nested))
283
+ return undefined;
284
+ const label = fallbackLabel(node);
285
+ if (nested.length === 0) {
286
+ context.notes.push(`${label}: the loop has no steps`);
287
+ return undefined;
288
+ }
289
+ const nodes = nested;
290
+ const start = findStart(nodes);
291
+ if (start === undefined)
292
+ return undefined;
293
+ const graph = buildGraph(nodes);
294
+ const inner = { ...context, graph, drawn: new Set() };
295
+ const { block } = layout(start.uuid, new Set(), inner);
296
+ for (const warning of graphWarnings(nodes, graph.byUuid, inner.drawn)) {
297
+ context.notes.push(`${label}: ${warning}`);
298
+ }
299
+ return frame(block, "per item");
300
+ }
301
+ /**
302
+ * Draw the flow rooted at `uuid`, stopping when it reaches anything in `stop`.
303
+ * The returned block's spine is live at the bottom only when `open` is true —
304
+ * a caller merging rails back together needs to know whether there is anything
305
+ * left to merge.
306
+ */
307
+ function layout(uuid, stop, context) {
308
+ if (stop.has(uuid))
309
+ return { block: emptyBlock(), open: true };
310
+ const node = context.graph.byUuid.get(uuid);
311
+ if (node === undefined)
312
+ return { block: emptyBlock(), open: false };
313
+ // An `end` is allowed to appear more than once: five paths finishing at `end`
314
+ // read better as five `end`s than as five pointers to one. Every other node
315
+ // is referenced on a second arrival, leaf or not — drawing a named step like
316
+ // "Send to #best-leads" twice reads as two separate sends.
317
+ const edges = edgesFrom(context.graph, uuid);
318
+ if (context.drawn.has(uuid) && !isEnd(node)) {
319
+ return { block: referenceBlock(node, context), open: false };
320
+ }
321
+ context.drawn.add(uuid);
322
+ const nested = subGraph(node, context);
323
+ const head = nested === undefined
324
+ ? textBlock(nodeLines(node, context))
325
+ : vstack([textBlock(nodeLines(node, context)), spineBlock(), nested]);
326
+ if (edges.length === 0)
327
+ return { block: head, open: false };
328
+ if (edges.length === 1) {
329
+ const edge = edges[0];
330
+ if (edge === undefined)
331
+ return { block: head, open: false };
332
+ const tail = layout(edge.to, stop, context);
333
+ // A lone edge usually has no label and draws as a bare `│`. When it does
334
+ // carry one — a branch whose other arm is dangling, or a step whose only
335
+ // outgoing edge is its fallback — the label goes above the stem, the way
336
+ // every multi-edge column draws it, so it reads as an edge label rather
337
+ // than as the name of the step below it.
338
+ const stem = spineBlock(edge.dashed === true ? "┆" : "│");
339
+ const connector = edge.label === undefined ? stem : vstack([textBlock([edge.label]), stem]);
340
+ if (tail.block.lines.length === 0) {
341
+ return { block: vstack([head, connector]), open: true };
342
+ }
343
+ return {
344
+ block: vstack([head, connector, tail.block]),
345
+ open: tail.open,
346
+ };
347
+ }
348
+ return branch(head, edges, stop, context);
349
+ }
350
+ function branch(head, edges, stop, context) {
351
+ // An elided routing node contributes no rows, and the step above it already
352
+ // drew the `│` leading in — adding another leaves a two-row gap above every
353
+ // split in a graph whose branches are unnamed.
354
+ const lead = head.lines.length === 0 ? emptyBlock() : spineBlock();
355
+ const merge = mergePoint(context.graph, edges.map((edge) => edge.to));
356
+ const spineIndex = chooseSpine(context.graph, edges, merge !== undefined);
357
+ const innerStop = new Set(stop);
358
+ if (merge !== undefined)
359
+ innerStop.add(merge);
360
+ // Columns are label-over-body so `hstack` sizes each one around whichever is
361
+ // wider, and an edge label can never collide with the node beneath it.
362
+ const columns = edges.map((edge) => {
363
+ const drawn = layout(edge.to, innerStop, context);
364
+ const char = edge.dashed === true ? "┆" : "│";
365
+ return {
366
+ block: vstack([
367
+ textBlock([edge.label === undefined ? char : edge.label]),
368
+ spineBlock(char),
369
+ drawn.block,
370
+ ]),
371
+ open: drawn.open,
372
+ char,
373
+ };
374
+ });
375
+ const height = Math.max(...columns.map((column) => column.block.lines.length));
376
+ const padded = columns.map((column) => padColumn(column.block,
377
+ // A column still carrying the flow is drawn to the bottom of the split,
378
+ // so its spine is unbroken all the way to whatever joins it below. One
379
+ // that has ended stops where it ended — the difference is how the reader
380
+ // tells a path that continues from a path that terminates.
381
+ column.open ? height : column.block.lines.length, column.char));
382
+ // The spine column is drawn leftmost, whichever child holds it, so rails
383
+ // always peel off to the right. Ordering columns by edge index instead pushes
384
+ // the spine one indent further right at every nesting level — five stages of
385
+ // a waterfall then stair-step across 80 columns rather than staying in one
386
+ // vertical line. Labels ride their own rail, so nothing is mislabelled by the
387
+ // reordering; only the left-to-right order of the arms changes.
388
+ const order = spineIndex === undefined
389
+ ? padded.map((_, index) => index)
390
+ : [
391
+ spineIndex,
392
+ ...padded
393
+ .map((_, index) => index)
394
+ .filter((index) => index !== spineIndex),
395
+ ];
396
+ const laid = hstack(order.map((index) => {
397
+ const column = padded[index];
398
+ return column === undefined ? emptyBlock() : column;
399
+ }), GAP);
400
+ const body = laid.block;
401
+ const width = body.width;
402
+ const spines = new Array(padded.length).fill(0);
403
+ order.forEach((original, position) => {
404
+ const at = laid.spines[position];
405
+ spines[original] = at === undefined ? 0 : at;
406
+ });
407
+ const isDetour = spineIndex !== undefined;
408
+ const first = spines[0];
409
+ const last = spines[spines.length - 1];
410
+ const midpoint = Math.round(((first === undefined ? 0 : first) + (last === undefined ? 0 : last)) / 2);
411
+ // Snap the incoming line onto a column when it lands beside one: `┴` and `┬`
412
+ // printed in adjacent cells read as a smudge, not a junction.
413
+ const anchor = columnAnchor(spines, spineIndex, midpoint);
414
+ const others = spines.filter((_, index) => index !== spineIndex);
415
+ // A detour keeps its spine and hangs the rails off it; a fork has no spine to
416
+ // keep, so the incoming line lands between the columns instead.
417
+ const splitRail = isDetour
418
+ ? rail(width, spines.map((at, index) => ({
419
+ at,
420
+ char: index === spineIndex
421
+ ? teeAt(anchor, others, true)
422
+ : at > anchor
423
+ ? "┐"
424
+ : "┌",
425
+ })))
426
+ : rail(width, [
427
+ ...spines.map((at, index) => ({
428
+ at,
429
+ char: at === anchor
430
+ ? "┼"
431
+ : index === 0
432
+ ? "┌"
433
+ : index === spines.length - 1
434
+ ? "┐"
435
+ : "┬",
436
+ })),
437
+ ...(spines.includes(anchor) ? [] : [{ at: anchor, char: "┴" }]),
438
+ ]);
439
+ const top = vstack([head, lead]);
440
+ const aligned = indent({ lines: [splitRail, ...body.lines], width, spine: anchor }, Math.max(0, top.spine - anchor));
441
+ const whole = vstack([top, aligned]);
442
+ const mergeAt = aligned.spine;
443
+ // Every column still carrying the flow is joined back to one spine, whether
444
+ // this branch owns the merge point or an ancestor does. A column left hanging
445
+ // reads as a path that stops, which is a different claim about the workflow.
446
+ const shift = aligned.width - width;
447
+ const arms = spines
448
+ .map((at, index) => ({
449
+ at: at + shift,
450
+ open: isColumnOpen(columns, index),
451
+ }))
452
+ .filter(({ at, open }) => open && at !== mergeAt)
453
+ .map(({ at }) => at);
454
+ const joined = arms.length === 0
455
+ ? []
456
+ : [
457
+ {
458
+ lines: [
459
+ rail(aligned.width, [
460
+ { at: mergeAt, char: teeAt(mergeAt, arms, false) },
461
+ ...arms.map((at) => ({
462
+ at,
463
+ char: at > mergeAt ? "┘" : "└",
464
+ })),
465
+ ]),
466
+ ],
467
+ width: aligned.width,
468
+ spine: mergeAt,
469
+ },
470
+ ];
471
+ if (merge === undefined) {
472
+ // For a detour, the spine column's status determines the flow. For a fork,
473
+ // any open column means the flow continues — including the column at the
474
+ // anchor, which `arms` filters out because it needs no rail to reach it.
475
+ const spineOpen = isDetour
476
+ ? isColumnOpen(columns, spineIndex)
477
+ : columns.some((column) => column.open);
478
+ return { block: vstack([whole, ...joined]), open: spineOpen };
479
+ }
480
+ const after = layout(merge, stop, context);
481
+ return {
482
+ block: vstack([whole, ...joined, spineBlock(), after.block]),
483
+ open: after.open,
484
+ };
485
+ }
486
+ // ---------------------------------------------------------------------------
487
+ // Entry point
488
+ // ---------------------------------------------------------------------------
489
+ export function renderNodeAscii(nodes, options = {}) {
490
+ const graph = buildGraph(nodes);
491
+ const warnings = [];
492
+ const start = findStart(nodes);
493
+ if (start === undefined) {
494
+ return { diagram: "", format: "ascii", warnings: ["no nodes to draw"] };
495
+ }
496
+ const context = {
497
+ graph,
498
+ names: options.names,
499
+ paid: options.paid === undefined ? [] : options.paid,
500
+ highlight: options.highlight === undefined ? [] : options.highlight,
501
+ drawn: new Set(),
502
+ notes: warnings,
503
+ };
504
+ const { block } = layout(start.uuid, new Set(), context);
505
+ warnings.push(...graphWarnings(nodes, graph.byUuid, context.drawn));
506
+ // Rails stack rightward, so a graph that branches at every step of a long
507
+ // chain gets wide no matter how the columns are packed — each rail has to
508
+ // clear the one belonging to the step below it. Say so rather than letting it
509
+ // wrap in the reader's terminal and look like a rendering bug.
510
+ const widest = Math.max(0, ...block.lines.map((line) => displayWidth(line)));
511
+ if (widest > TERMINAL_WIDTH) {
512
+ warnings.push(`the drawing is ${String(widest)} columns wide and will wrap in most terminals — try --format mermaid, or diagram a sub-graph`);
513
+ }
514
+ const body = block.lines.map((line) => line.replace(/\s+$/, "")).join("\n");
515
+ const title = options.title === undefined ? undefined : options.title.trim();
516
+ if (title === undefined || title.length === 0) {
517
+ return { diagram: body, format: "ascii", warnings };
518
+ }
519
+ // Centre the title on the spine, not on the drawing's bounding box — the
520
+ // spine is what the eye follows down, and a wide rail off to one side would
521
+ // otherwise drag the title away from it.
522
+ const left = Math.max(0, block.spine - Math.floor(displayWidth(title) / 2));
523
+ return {
524
+ diagram: `${" ".repeat(left)}${title}\n\n${body}`,
525
+ format: "ascii",
526
+ warnings,
527
+ };
528
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cargo-ai/cli",
3
- "version": "1.0.55",
3
+ "version": "1.0.56",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://getcargo.ai",